From 90f0fc372260dde474275beece2094b033270186 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sat, 24 Oct 2026 23:08:36 -0700 Subject: [PATCH 01/44] fix(hooks): run only the checked root hook config --- .github/workflows/check.yaml | 6 +- .../workflows/prepare-towncrier-release.yaml | 2 +- .github/workflows/release.yaml | 2 +- .../workflows/validate-python-release.yaml | 2 +- changelog.d/+root-hook-config.security.md | 3 + check-hook-update-cooldown/action.yml | 12 +++- prepare-towncrier/action.yml | 4 +- .../test_cooldown.py | 39 +++++++++- tests/conftest.py | 13 +++- tests/release_workflow/test_graph.py | 2 +- tests/verify_frozen_hooks/test_verify.py | 71 +++++++++++++++++++ validate-release-patch/action.yml | 2 +- verify-frozen-hooks/action.yml | 10 +++ 13 files changed, 152 insertions(+), 16 deletions(-) create mode 100644 changelog.d/+root-hook-config.security.md create mode 100644 tests/verify_frozen_hooks/test_verify.py diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index 2e149ed..e5aa28a 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -46,7 +46,7 @@ jobs: unset UV_CONFIG_FILE UV_INDEX UV_INDEX_URL UV_DEFAULT_INDEX UV_EXTRA_INDEX_URL unset UV_FIND_LINKS UV_EXCLUDE_NEWER_PACKAGE unset UV_INSECURE_HOST UV_SYSTEM_CERTS SSL_CERT_FILE SSL_CERT_DIR - prek run --all-files + prek run --config .pre-commit-config.yaml --all-files shell: runs-on: ubuntu-26.04 permissions: @@ -96,8 +96,8 @@ jobs: index=$((index + 1)) done <"$RUNNER_TEMP/run-blocks" done - prek run shellcheck --files "$script_dir"/*.sh - prek run shfmt --files "$script_dir"/*.sh + prek run --config .pre-commit-config.yaml shellcheck --files "$script_dir"/*.sh + prek run --config .pre-commit-config.yaml shfmt --files "$script_dir"/*.sh test: runs-on: ubuntu-26.04 permissions: diff --git a/.github/workflows/prepare-towncrier-release.yaml b/.github/workflows/prepare-towncrier-release.yaml index 54c9f9d..87dfafc 100644 --- a/.github/workflows/prepare-towncrier-release.yaml +++ b/.github/workflows/prepare-towncrier-release.yaml @@ -110,7 +110,7 @@ jobs: unset UV_CONFIG_FILE UV_INDEX UV_INDEX_URL UV_DEFAULT_INDEX UV_EXTRA_INDEX_URL unset UV_FIND_LINKS UV_EXCLUDE_NEWER_PACKAGE unset UV_INSECURE_HOST UV_SYSTEM_CERTS SSL_CERT_FILE SSL_CERT_DIR - prek install-hooks + prek install-hooks --config .pre-commit-config.yaml background: true - name: Build local release tool wheel if: inputs.local-tool-source diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 43de07c..8f5d114 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -41,7 +41,7 @@ jobs: unset UV_CONFIG_FILE UV_INDEX UV_INDEX_URL UV_DEFAULT_INDEX UV_EXTRA_INDEX_URL unset UV_FIND_LINKS UV_EXCLUDE_NEWER_PACKAGE unset UV_INSECURE_HOST UV_SYSTEM_CERTS SSL_CERT_FILE SSL_CERT_DIR - .venv/bin/prek run --all-files + .venv/bin/prek run --config .pre-commit-config.yaml --all-files source: runs-on: ubuntu-26.04 permissions: diff --git a/.github/workflows/validate-python-release.yaml b/.github/workflows/validate-python-release.yaml index a8f5b6e..caa4b76 100644 --- a/.github/workflows/validate-python-release.yaml +++ b/.github/workflows/validate-python-release.yaml @@ -86,7 +86,7 @@ jobs: unset UV_CONFIG_FILE UV_INDEX UV_INDEX_URL UV_DEFAULT_INDEX UV_EXTRA_INDEX_URL unset UV_FIND_LINKS UV_EXCLUDE_NEWER_PACKAGE unset UV_INSECURE_HOST UV_SYSTEM_CERTS SSL_CERT_FILE SSL_CERT_DIR - "$RUNNER_TEMP/release-tools/bin/prek" run --all-files + "$RUNNER_TEMP/release-tools/bin/prek" run --config .pre-commit-config.yaml --all-files constraints: runs-on: ubuntu-26.04 outputs: diff --git a/changelog.d/+root-hook-config.security.md b/changelog.d/+root-hook-config.security.md new file mode 100644 index 0000000..af36894 --- /dev/null +++ b/changelog.d/+root-hook-config.security.md @@ -0,0 +1,3 @@ +Run every prek command with `--config .pre-commit-config.yaml`, and reject a `prek.toml`, a `.pre-commit-config.yml`, or +a nested `.pre-commit-config.yaml` in the hook cooldown and frozen hook checks. prek used to run those files without any +revision, cooldown, or dependency check. diff --git a/check-hook-update-cooldown/action.yml b/check-hook-update-cooldown/action.yml index b4bdbc9..d9df126 100644 --- a/check-hook-update-cooldown/action.yml +++ b/check-hook-update-cooldown/action.yml @@ -35,9 +35,17 @@ runs: .head.sha == $head and (.user.login | type == "string") ' "$RUNNER_TEMP/hook-update-pr.json" >/dev/null - gh api --paginate "repos/$REPOSITORY/pulls/$PR_NUMBER/files" --jq '.[].filename' \ + gh api --paginate "repos/$REPOSITORY/pulls/$PR_NUMBER/files" --jq '.[] | [.status, .filename] | @tsv' \ >"$RUNNER_TEMP/hook-update-files" - if grep -Fxq .pre-commit-config.yaml "$RUNNER_TEMP/hook-update-files"; then + while IFS=$'\t' read -r status file; do + name=${file##*/} + if [[ "$status" != removed && ("$name" == prek.toml || "$name" == .pre-commit-config.yml || + ("$name" == .pre-commit-config.yaml && "$file" != .pre-commit-config.yaml)) ]]; then + echo "::error::hooks must be configured only in the root .pre-commit-config.yaml: $file" >&2 + exit 1 + fi + done <"$RUNNER_TEMP/hook-update-files" + if cut -f2 "$RUNNER_TEMP/hook-update-files" | grep -Fxq .pre-commit-config.yaml; then if [[ $(jq -r .user.type "$RUNNER_TEMP/hook-update-pr.json") == Bot ]]; then jq -e --arg repository "$REPOSITORY" '.head.repo.full_name == $repository' \ "$RUNNER_TEMP/hook-update-pr.json" >/dev/null || { diff --git a/prepare-towncrier/action.yml b/prepare-towncrier/action.yml index ad5beb9..26fc8c8 100644 --- a/prepare-towncrier/action.yml +++ b/prepare-towncrier/action.yml @@ -115,8 +115,8 @@ runs: exit 1 ;; esac - if ! prek run "$formatter" --files "$CHANGELOG"; then - prek run "$formatter" --files "$CHANGELOG" + if ! prek run --config .pre-commit-config.yaml "$formatter" --files "$CHANGELOG"; then + prek run --config .pre-commit-config.yaml "$formatter" --files "$CHANGELOG" fi - name: Check generated files if: inputs.phase != 'build' diff --git a/tests/check_hook_update_cooldown/test_cooldown.py b/tests/check_hook_update_cooldown/test_cooldown.py index feeceba..ccc223d 100644 --- a/tests/check_hook_update_cooldown/test_cooldown.py +++ b/tests/check_hook_update_cooldown/test_cooldown.py @@ -332,6 +332,39 @@ def test_cooldown_checks_human_local_hook_dependencies( assert (tmp_path / "hook-closure/.pre-commit-config.yaml").read_text() == proposed +@pytest.mark.parametrize( + "path", + [ + pytest.param("prek.toml", id="root-prek-toml"), + pytest.param("sub/prek.toml", id="nested-prek-toml"), + pytest.param(".pre-commit-config.yml", id="root-yml"), + pytest.param("sub/.pre-commit-config.yaml", id="nested-yaml"), + pytest.param("sub/.pre-commit-config.yml", id="nested-yml"), + ], +) +def test_cooldown_rejects_other_hook_configuration( + tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]], path: str +) -> None: + server, environment = github_api + expect_pull(server, author="gaborbernat", hook_update=False, other_files=(("added", path),)) + + result = run_cooldown(action_scripts, tmp_path, environment) + + assert result.returncode != 0 + assert f"only in the root .pre-commit-config.yaml: {path}" in result.stderr + + +def test_cooldown_allows_removing_other_hook_configuration( + tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]] +) -> None: + server, environment = github_api + expect_pull(server, author="gaborbernat", hook_update=False, other_files=(("removed", "prek.toml"),)) + + result = run_cooldown(action_scripts, tmp_path, environment) + + assert result.returncode == 0, result.stderr + + def test_cooldown_skips_bot_without_hook_changes( tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]] ) -> None: @@ -742,6 +775,7 @@ def expect_pull( associated_pull_request: int | None = 7, observed_repository: str | None = None, workflow_path: str = ".github/workflows/check.yaml", + other_files: tuple[tuple[str, str], ...] = (), ) -> None: server.expect_request("/api/v3/repos/tox-dev/fixture/pulls/7", method="GET").respond_with_json( { @@ -754,7 +788,10 @@ def expect_pull( } ) server.expect_request("/api/v3/repos/tox-dev/fixture/pulls/7/files", method="GET").respond_with_json( - [{"filename": ".pre-commit-config.yaml" if hook_update else "pyproject.toml"}] + [ + {"status": "modified", "filename": ".pre-commit-config.yaml" if hook_update else "pyproject.toml"}, + *({"status": status, "filename": filename} for status, filename in other_files), + ] ) if hook_update: if author.endswith("[bot]") and branch == "automation/update-release-tools": diff --git a/tests/conftest.py b/tests/conftest.py index c4555e1..5b7b3f6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -75,6 +75,7 @@ def action_scripts(tmp_path_factory: pytest.TempPathFactory) -> Path: "check-hook-update-cooldown", "recheck-hook-update-cooldown", "publish-post-release-pr", + "verify-frozen-hooks", ): action = yaml.safe_load((source / action_name / "action.yml").read_text()) for index, step in enumerate(action["runs"]["steps"]): @@ -528,9 +529,11 @@ def _condition( return any(_condition(part, environment, inputs, step_outputs) for part in expression.split(" || ")) if " && " in expression: return all(_condition(part, environment, inputs, step_outputs) for part in expression.split(" && ")) - if expression.startswith("endsWith(") and expression.endswith(")"): - arguments = expression.removeprefix("endsWith(").removesuffix(")").split(", ", 1) - return _expression(arguments[0], environment, inputs, step_outputs).endswith(arguments[1].strip("'")) + for function in ("startsWith", "endsWith"): + if expression.startswith(f"{function}(") and expression.endswith(")"): + subject, affix = expression.removeprefix(f"{function}(").removesuffix(")").split(", ", 1) + value = _expression(subject, environment, inputs, step_outputs) + return value.startswith(affix.strip("'")) if function == "startsWith" else value.endswith(affix.strip("'")) for operator in (" == ", " != "): if operator in expression: left, right = expression.split(operator, 1) @@ -637,6 +640,10 @@ def _expression( return step_outputs.get(step_id, {}).get(output_name, "") if expression == "runner.temp": return environment["RUNNER_TEMP"] + if expression == "github.ref": + return environment.get("GITHUB_REF", "") + if expression == "github.ref_name": + return environment.get("GITHUB_REF_NAME", "") if expression == "github.repository": return environment["GITHUB_REPOSITORY"] if expression == "github.base_ref": diff --git a/tests/release_workflow/test_graph.py b/tests/release_workflow/test_graph.py index 6d22ebc..6a2af08 100644 --- a/tests/release_workflow/test_graph.py +++ b/tests/release_workflow/test_graph.py @@ -190,7 +190,7 @@ def test_prepare_installs_hooks_while_building_changelog() -> None: wait = next(step for step in steps if step.get("wait") == "hooks") finalize = next(step for step in steps if step.get("name") == "Finalize release patch") assert hooks["background"] is True - assert hooks["run"].splitlines()[-1] == "prek install-hooks" + assert hooks["run"].splitlines()[-1] == "prek install-hooks --config .pre-commit-config.yaml" assert [build["with"]["phase"], finalize["with"]["phase"]] == ["build", "finalize"] assert steps.index(hooks) < steps.index(build) < steps.index(wait) < steps.index(finalize) diff --git a/tests/verify_frozen_hooks/test_verify.py b/tests/verify_frozen_hooks/test_verify.py new file mode 100644 index 0000000..59ab1a6 --- /dev/null +++ b/tests/verify_frozen_hooks/test_verify.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest +from conftest import action, run + +LOCAL_HOOK = "repos:\n - repo: local\n hooks:\n - id: noop\n name: noop\n entry: 'true'\n language: system\n" + + +def _repository(tmp_path: Path, config: str, *extra: str) -> Path: + repository = tmp_path / "source" + repository.mkdir() + run(repository, "git", "init", "-q") + (repository / ".pre-commit-config.yaml").write_text(config) + for path in extra: + (repository / path).parent.mkdir(parents=True, exist_ok=True) + (repository / path).write_text(LOCAL_HOOK) + run(repository, "git", "add", ".") + run(repository, "git", "-c", "user.name=Test", "-c", "user.email=test@example.org", "commit", "-qm", "Hooks") + return repository + + +def test_verify_frozen_hooks_accepts_root_configuration(tmp_path: Path, action_scripts: Path) -> None: + repository = _repository(tmp_path, LOCAL_HOOK) + + result = action(repository, tmp_path, action_scripts, "verify-frozen-hooks", version="") + + assert result.returncode == 0, result.stderr + + +@pytest.mark.parametrize( + "path", + [ + pytest.param("prek.toml", id="root-prek-toml"), + pytest.param("sub/prek.toml", id="nested-prek-toml"), + pytest.param(".pre-commit-config.yml", id="root-yml"), + pytest.param("sub/.pre-commit-config.yaml", id="nested-yaml"), + ], +) +def test_verify_frozen_hooks_rejects_other_configuration(tmp_path: Path, action_scripts: Path, path: str) -> None: + repository = _repository(tmp_path, LOCAL_HOOK, path) + + result = action(repository, tmp_path, action_scripts, "verify-frozen-hooks", version="") + + assert result.returncode != 0 + assert f"only in the root .pre-commit-config.yaml: {path}" in result.stderr + + +def test_verify_frozen_hooks_rejects_movable_revision(tmp_path: Path, action_scripts: Path) -> None: + repository = _repository( + tmp_path, "repos:\n - repo: https://github.com/example/hooks\n rev: v1\n hooks: []\n" + ) + + result = action(repository, tmp_path, action_scripts, "verify-frozen-hooks", version="") + + assert result.returncode != 0 + assert "full lowercase commit SHA" in result.stderr + + +def test_verify_frozen_hooks_rejects_missing_managed_pins(tmp_path: Path, action_scripts: Path) -> None: + repository = _repository( + tmp_path, + "repos:\n - repo: local\n hooks:\n - id: fetch\n name: fetch\n entry: 'true'\n" + " language: python\n additional_dependencies: [requests==2.31.0]\n", + ) + + result = action(repository, tmp_path, action_scripts, "verify-frozen-hooks", version="") + + assert result.returncode != 0 + assert "pins do not match the resolved closure" in result.stderr diff --git a/validate-release-patch/action.yml b/validate-release-patch/action.yml index 15e328e..74eaddb 100644 --- a/validate-release-patch/action.yml +++ b/validate-release-patch/action.yml @@ -136,7 +136,7 @@ runs: unset UV_FIND_LINKS UV_EXCLUDE_NEWER_PACKAGE unset UV_INSECURE_HOST UV_SYSTEM_CERTS SSL_CERT_FILE SSL_CERT_DIR set -euo pipefail - if ! prek run --all-files; then + if ! prek run --config .pre-commit-config.yaml --all-files; then echo '::error::prek failed on the validated release patch' >&2 exit 1 fi diff --git a/verify-frozen-hooks/action.yml b/verify-frozen-hooks/action.yml index efb1b36..79d3a8b 100644 --- a/verify-frozen-hooks/action.yml +++ b/verify-frozen-hooks/action.yml @@ -7,6 +7,16 @@ inputs: runs: using: composite steps: + - name: Reject other hook configurations + shell: bash + run: | + set -euo pipefail + others=$(git ls-files -- ':(glob)**/prek.toml' ':(glob)**/.pre-commit-config.yml' \ + ':(glob)*/**/.pre-commit-config.yaml') + if [[ -n "$others" ]]; then + echo "::error::hooks must be configured only in the root .pre-commit-config.yaml: ${others//$'\n'/, }" >&2 + exit 1 + fi - uses: $/install-locked-tools with: groups: release-notes From 6f37f2fd915739bf425d06fcbb93d118da20fe11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sat, 24 Oct 2026 23:09:42 -0700 Subject: [PATCH 02/44] fix(hooks): prove tagged revisions before builds --- changelog.d/+tagged-hook-order.security.md | 2 ++ tests/verify_frozen_hooks/test_verify.py | 23 ++++++++++++++++++++++ verify-frozen-hooks/action.yml | 6 +++--- 3 files changed, 28 insertions(+), 3 deletions(-) create mode 100644 changelog.d/+tagged-hook-order.security.md diff --git a/changelog.d/+tagged-hook-order.security.md b/changelog.d/+tagged-hook-order.security.md new file mode 100644 index 0000000..44bbcf7 --- /dev/null +++ b/changelog.d/+tagged-hook-order.security.md @@ -0,0 +1,2 @@ +Prove the age of tagged hook revisions before `release-creator hooks --check` builds hook repositories, so build +backends from an unproven revision do not run first. diff --git a/tests/verify_frozen_hooks/test_verify.py b/tests/verify_frozen_hooks/test_verify.py index 59ab1a6..0d79830 100644 --- a/tests/verify_frozen_hooks/test_verify.py +++ b/tests/verify_frozen_hooks/test_verify.py @@ -69,3 +69,26 @@ def test_verify_frozen_hooks_rejects_missing_managed_pins(tmp_path: Path, action assert result.returncode != 0 assert "pins do not match the resolved closure" in result.stderr + + +def test_verify_frozen_hooks_proves_tagged_revisions_before_building_hooks( + tmp_path: Path, action_scripts: Path +) -> None: + repository = _repository( + tmp_path, + "repos:\n - repo: local\n hooks:\n - id: fetch\n name: fetch\n entry: 'true'\n" + " language: python\n additional_dependencies: [requests==2.31.0]\n", + ) + + result = action( + repository, + tmp_path, + action_scripts, + "verify-frozen-hooks", + version="", + GITHUB_REF="refs/tags/v1.0.0", + GITHUB_REF_NAME="v1.0.0", + ) + + assert result.returncode != 0 + assert "pins do not match the resolved closure" not in result.stderr diff --git a/verify-frozen-hooks/action.yml b/verify-frozen-hooks/action.yml index 79d3a8b..2224ecd 100644 --- a/verify-frozen-hooks/action.yml +++ b/verify-frozen-hooks/action.yml @@ -27,9 +27,6 @@ runs: run: | uv run --no-project --python "$RUNNER_TEMP/frozen-hook-tools" python \ "$GITHUB_ACTION_PATH/../tools/check_frozen_hooks.py" .pre-commit-config.yaml - - name: Check Python hook dependencies - shell: bash - run: uv run --no-project --python "$RUNNER_TEMP/frozen-hook-tools" release-creator hooks --check - name: Check tagged hook revision provenance if: inputs.tag != '' || startsWith(github.ref, 'refs/tags/') shell: bash @@ -42,3 +39,6 @@ runs: uv run --no-project --python "$RUNNER_TEMP/frozen-hook-tools" python \ "$GITHUB_ACTION_PATH/../tools/check_tagged_hook_age.py" \ "$GITHUB_REPOSITORY" "$TAG" + - name: Check Python hook dependencies + shell: bash + run: uv run --no-project --python "$RUNNER_TEMP/frozen-hook-tools" release-creator hooks --check From f094b32f4f47d62894c504f42e1d08489f71b283 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sat, 24 Oct 2026 23:09:42 -0700 Subject: [PATCH 03/44] fix(release): check tag ancestry before hooks --- .github/workflows/release.yaml | 9 +++++++++ .github/workflows/validate-python-release.yaml | 9 +++++++++ changelog.d/+tagged-source-main.security.md | 1 + tests/validate_python_release/test_tagged.py | 18 +++++++++++++----- 4 files changed, 32 insertions(+), 5 deletions(-) create mode 100644 changelog.d/+tagged-source-main.security.md diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 8f5d114..5dbdfb1 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -19,6 +19,15 @@ jobs: with: fetch-depth: 0 persist-credentials: false + - name: Verify the exact release tag + env: + TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + test "$GITHUB_REF" = "refs/tags/$TAG" + git check-ref-format "refs/tags/$TAG" + test "$(git rev-parse "refs/tags/$TAG^{commit}")" = "$(git rev-parse HEAD)" + git merge-base --is-ancestor HEAD origin/main - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 with: version: latest-known diff --git a/.github/workflows/validate-python-release.yaml b/.github/workflows/validate-python-release.yaml index caa4b76..c93415a 100644 --- a/.github/workflows/validate-python-release.yaml +++ b/.github/workflows/validate-python-release.yaml @@ -57,6 +57,15 @@ jobs: fetch-depth: 0 persist-credentials: false ref: ${{ github.sha }} + - name: Verify the exact release tag + env: + TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + test "$GITHUB_REF" = "refs/tags/$TAG" + git check-ref-format "refs/tags/$TAG" + test "$(git rev-parse "refs/tags/$TAG^{commit}")" = "$(git rev-parse HEAD)" + git merge-base --is-ancestor HEAD origin/main - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 with: version: latest-known diff --git a/changelog.d/+tagged-source-main.security.md b/changelog.d/+tagged-source-main.security.md new file mode 100644 index 0000000..130ef52 --- /dev/null +++ b/changelog.d/+tagged-source-main.security.md @@ -0,0 +1 @@ +Check that the release tag points at the checkout and is on `main` before the tagged source check runs hooks. diff --git a/tests/validate_python_release/test_tagged.py b/tests/validate_python_release/test_tagged.py index b84e4b7..7f790b5 100644 --- a/tests/validate_python_release/test_tagged.py +++ b/tests/validate_python_release/test_tagged.py @@ -192,7 +192,17 @@ def test_validator_rejects_backend_hash_mismatch( pytest.param("off-main", "refs/tags/0.2.0", False, id="tagged-off-main-commit"), ], ) -def test_validator_requires_exact_tag(repository: Path, tag_kind: str, event_ref: str, accepted: bool) -> None: +@pytest.mark.parametrize( + ("workflow_name", "job"), + [ + pytest.param("validate-python-release", "source-check", id="source-check"), + pytest.param("validate-python-release", "build", id="build"), + pytest.param("release", "check", id="self-release"), + ], +) +def test_validator_requires_exact_tag( + repository: Path, tag_kind: str, event_ref: str, accepted: bool, workflow_name: str, job: str +) -> None: if tag_kind == "branch": run(repository, "git", "branch", "0.2.0") elif tag_kind == "off-main": @@ -204,11 +214,9 @@ def test_validator_requires_exact_tag(repository: Path, tag_kind: str, event_ref else: run(repository, "git", "tag", "0.2.0") workflow = yaml.safe_load( - (Path(__file__).resolve().parents[2] / ".github/workflows/validate-python-release.yaml").read_text() - ) - verify = next( - step for step in workflow["jobs"]["build"]["steps"] if step.get("name") == "Verify the exact release tag" + (Path(__file__).resolve().parents[2] / ".github/workflows" / f"{workflow_name}.yaml").read_text() ) + verify = next(step for step in workflow["jobs"][job]["steps"] if step.get("name") == "Verify the exact release tag") result = subprocess.run( ["bash", "-c", verify["run"]], cwd=repository, From 50c7f0f48bef8f9ffe52842f507561326b8279c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sat, 24 Oct 2026 23:10:13 -0700 Subject: [PATCH 04/44] fix(hooks): reject non-Python hook dependencies --- .../+python-hook-dependencies.security.md | 2 ++ .../test_frozen_hooks.py | 19 +++++++++++++++++-- tools/check_frozen_hooks.py | 7 +++++++ 3 files changed, 26 insertions(+), 2 deletions(-) create mode 100644 changelog.d/+python-hook-dependencies.security.md diff --git a/changelog.d/+python-hook-dependencies.security.md b/changelog.d/+python-hook-dependencies.security.md new file mode 100644 index 0000000..35b7e59 --- /dev/null +++ b/changelog.d/+python-hook-dependencies.security.md @@ -0,0 +1,2 @@ +Reject `additional_dependencies` on hooks whose configured `language` is not Python. Only Python hook dependencies get +exact, cooled pins, so a node or other hook would install them unpinned. diff --git a/tests/check_hook_update_cooldown/test_frozen_hooks.py b/tests/check_hook_update_cooldown/test_frozen_hooks.py index dbb5321..d60ab93 100644 --- a/tests/check_hook_update_cooldown/test_frozen_hooks.py +++ b/tests/check_hook_update_cooldown/test_frozen_hooks.py @@ -32,9 +32,17 @@ def test_frozen_hook_revision(tmp_path: Path, revision: str, accepted: bool) -> assert "full lowercase commit SHA" in result.stderr -def test_frozen_hook_revision_allows_local_hook(tmp_path: Path) -> None: +@pytest.mark.parametrize( + "hooks", + [ + pytest.param("[]", id="no-hooks"), + pytest.param("[{id: lint, language: python, additional_dependencies: [ruff==0.1.0]}]", id="python-dependencies"), + pytest.param("[{id: lint, language: node}]", id="node-without-dependencies"), + ], +) +def test_frozen_hook_revision_allows_local_hook(tmp_path: Path, hooks: str) -> None: config = tmp_path / ".pre-commit-config.yaml" - config.write_text("repos:\n - repo: local\n hooks: []\n") + config.write_text(f"repos:\n - repo: local\n hooks: {hooks}\n") result = subprocess.run( [sys.executable, str(Path(__file__).resolve().parents[2] / "tools/check_frozen_hooks.py"), str(config)], text=True, @@ -50,6 +58,13 @@ def test_frozen_hook_revision_allows_local_hook(tmp_path: Path) -> None: [ pytest.param("repos: null\n", "repository list", id="missing-list"), pytest.param("repos:\n - local\n", "mapping with a URL", id="invalid-repository"), + pytest.param("repos:\n - repo: local\n hooks: [x]\n", "list of mappings", id="invalid-hooks"), + pytest.param( + "repos:\n - repo: local\n hooks:\n - id: lint\n language: node\n" + " additional_dependencies: [prettier@latest]\n", + "only Python hooks may declare additional_dependencies: lint", + id="node-dependencies", + ), ], ) def test_frozen_hook_revision_rejects_malformed_config(tmp_path: Path, config_text: str, message: str) -> None: diff --git a/tools/check_frozen_hooks.py b/tools/check_frozen_hooks.py index 26a3995..c3587ab 100644 --- a/tools/check_frozen_hooks.py +++ b/tools/check_frozen_hooks.py @@ -26,6 +26,13 @@ def check_frozen(path: Path) -> None: for repository in config["repos"]: if not isinstance(repository, dict) or not isinstance(repository.get("repo"), str): raise TypeError("hook repository must be a mapping with a URL") + hooks = repository.get("hooks", []) + if not isinstance(hooks, list) or not all(isinstance(hook, dict) for hook in hooks): + raise TypeError(f"hooks must be a list of mappings: {repository['repo']}") + for hook in hooks: + # release-creator pins and cools only Python hook dependencies; other ecosystems would install unpinned. + if hook.get("additional_dependencies") and hook.get("language") not in {None, "python"}: + raise ValueError(f"only Python hooks may declare additional_dependencies: {hook.get('id')}") if repository["repo"] in {"local", "meta"}: continue revision = repository.get("rev") From 3e073b03c907fb4ec12c16a9feadb8add6346bc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sat, 24 Oct 2026 23:12:32 -0700 Subject: [PATCH 05/44] fix(marketplace): accept retries after reconciling --- .../+marketplace-retry-receipts.bugfix.md | 3 + publish-jetbrains-marketplace/action.yml | 68 ++++++++----- .../test_marketplace_upload.py | 96 ++++++++++++++++++- 3 files changed, 144 insertions(+), 23 deletions(-) create mode 100644 changelog.d/+marketplace-retry-receipts.bugfix.md diff --git a/changelog.d/+marketplace-retry-receipts.bugfix.md b/changelog.d/+marketplace-retry-receipts.bugfix.md new file mode 100644 index 0000000..01db312 --- /dev/null +++ b/changelog.d/+marketplace-retry-receipts.bugfix.md @@ -0,0 +1,3 @@ +Let a JetBrains Marketplace retry follow an attempt that reconciled an earlier upload. That attempt now keeps a copy of +the receipt it verified, and the check reads the artifacts of the run named in the receipt. Fork pull request runs on +a branch named like the tag no longer block the upload. diff --git a/publish-jetbrains-marketplace/action.yml b/publish-jetbrains-marketplace/action.yml index da545ea..5db4662 100644 --- a/publish-jetbrains-marketplace/action.yml +++ b/publish-jetbrains-marketplace/action.yml @@ -90,10 +90,11 @@ runs: set -euo pipefail encoded_tag=$(jq -rn --arg tag "$TAG" '$tag | @uri') gh api --paginate "repos/$GITHUB_REPOSITORY/actions/runs?branch=$encoded_tag&per_page=100" \ - --jq '.workflow_runs[] | [.id, .head_branch, .head_sha] | @tsv' >"$RUNNER_TEMP/marketplace-runs.tsv" + --jq '.workflow_runs[] | [.id, .head_branch, .head_sha, .head_repository.full_name] | @tsv' \ + >"$RUNNER_TEMP/marketplace-runs.tsv" prior=0 - while IFS=$'\t' read -r run branch sha; do - [[ "$branch" == "$TAG" ]] || continue + while IFS=$'\t' read -r run branch sha source; do + [[ "$branch" == "$TAG" && "$source" == "$GITHUB_REPOSITORY" ]] || continue gh api --paginate "repos/$GITHUB_REPOSITORY/actions/runs/$run/jobs?filter=all&per_page=100" \ --jq ".jobs[] | .run_attempt as \$attempt | .steps[] | select((.name == \"Publish signed ZIP\" or .name == \"Upload signed ZIP\" or @@ -111,19 +112,22 @@ runs: receipt_name="marketplace-acceptance-$run-$attempt" receipt_id=$(awk -F '\t' -v name="$receipt_name" '$1 == name && $3 == "false" { print $2 }' \ "$RUNNER_TEMP/marketplace-artifacts-$run.tsv") - if [[ -z "$receipt_id" || "$receipt_id" == *$'\n'* || "$prior" == 1 ]]; then + if [[ -z "$receipt_id" || "$receipt_id" == *$'\n'* ]]; then echo '::error::Marketplace upload may already be accepted.' \ "Reconcile run $run attempt $attempt manually." >&2 exit 1 fi prior=1 - { - echo "run=$run" - echo "receipt-name=$receipt_name" - echo "artifacts=$RUNNER_TEMP/marketplace-artifacts-$run.tsv" - } >>"$GITHUB_OUTPUT" + receipt_run=$run + receipt=$receipt_name done <"$RUNNER_TEMP/marketplace-attempts.txt" done <"$RUNNER_TEMP/marketplace-runs.tsv" + if ((prior)); then + { + echo "run=$receipt_run" + echo "receipt-name=$receipt" + } >>"$GITHUB_OUTPUT" + fi echo "accepted=$prior" >>"$GITHUB_OUTPUT" - name: Check earlier release deployments if: steps.earlier.outputs.accepted == '0' @@ -181,6 +185,28 @@ runs: echo "::error::Marketplace upload needs a release environment deployment for tag $TAG." >&2 exit 1 fi + - name: Download earlier Marketplace acceptance + if: steps.earlier.outputs.accepted == '1' + shell: bash + env: + GH_TOKEN: ${{ inputs.github-token }} + RUN: ${{ steps.earlier.outputs.run }} + RECEIPT_NAME: ${{ steps.earlier.outputs.receipt-name }} + run: | + set -euo pipefail + mkdir "$RUNNER_TEMP/prior-acceptance" + gh run download "$RUN" --repo "$GITHUB_REPOSITORY" --name "$RECEIPT_NAME" \ + --dir "$RUNNER_TEMP/prior-acceptance" + receipt="$RUNNER_TEMP/prior-acceptance/marketplace-acceptance.json" + [[ -f "$receipt" && ! -L "$receipt" ]] || exit 1 + # A later attempt finds this attempt's started upload step; the copy shows it skipped the upload. + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + if: steps.earlier.outputs.accepted == '1' + with: + name: marketplace-acceptance-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/prior-acceptance/marketplace-acceptance.json + if-no-files-found: error + retention-days: 30 - name: Verify earlier Marketplace acceptance if: steps.earlier.outputs.accepted == '1' shell: bash @@ -193,33 +219,31 @@ runs: ARCHIVE: ${{ inputs.archive }} EXPECTED_SHA: ${{ inputs.sha256 }} NOTES_FILE: ${{ inputs.notes-file }} - RUN: ${{ steps.earlier.outputs.run }} - RECEIPT_NAME: ${{ steps.earlier.outputs.receipt-name }} - ARTIFACTS: ${{ steps.earlier.outputs.artifacts }} run: | set -euo pipefail - mkdir "$RUNNER_TEMP/prior-acceptance" "$RUNNER_TEMP/prior-signed" "$RUNNER_TEMP/prior-notes" - gh run download "$RUN" --repo "$GITHUB_REPOSITORY" --name "$RECEIPT_NAME" \ - --dir "$RUNNER_TEMP/prior-acceptance" + mkdir "$RUNNER_TEMP/prior-signed" "$RUNNER_TEMP/prior-notes" receipt="$RUNNER_TEMP/prior-acceptance/marketplace-acceptance.json" - [[ -f "$receipt" && ! -L "$receipt" ]] || exit 1 jq -e --arg repository "$GITHUB_REPOSITORY" --arg tag "$TAG" --arg commit "$COMMIT" \ - --arg version "${TAG#v}" --argjson runId "$RUN" --argjson pluginId "$PLUGIN_ID" \ + --arg version "${TAG#v}" --argjson pluginId "$PLUGIN_ID" \ --arg channel "$CHANNEL" --arg archive "${ARCHIVE##*/}" --arg sha "$EXPECTED_SHA" \ --arg notesSha "$(sha256sum "$NOTES_FILE" | cut -d' ' -f1)" \ '.schema == 2 and .repository == $repository and .tag == $tag and .commit == $commit and - .sourceRunId == $runId and .pluginId == $pluginId and .version == $version and + (.sourceRunId | type == "number") and .pluginId == $pluginId and .version == $version and .channel == $channel and .signedArchive == $archive and .sha256 == $sha and .notesSha256 == $notesSha and (.signedArtifactId | type == "number") and (.notesArtifactId | type == "number")' "$receipt" >/dev/null + source_run=$(jq -r .sourceRunId "$receipt") + artifacts="$RUNNER_TEMP/marketplace-artifacts-$source_run.tsv" + # Only runs on this tag and commit with a started upload step have their artifacts listed. + [[ -f "$artifacts" ]] || exit 1 signed_id=$(jq -r .signedArtifactId "$receipt") notes_id=$(jq -r .notesArtifactId "$receipt") - prior_signed_id=$(awk -F '\t' '$1 == "plugin-distribution" && $3 == "false" { print $2 }' "$ARTIFACTS") - prior_notes_id=$(awk -F '\t' '$1 == "release-notes" && $3 == "false" { print $2 }' "$ARTIFACTS") + prior_signed_id=$(awk -F '\t' '$1 == "plugin-distribution" && $3 == "false" { print $2 }' "$artifacts") + prior_notes_id=$(awk -F '\t' '$1 == "release-notes" && $3 == "false" { print $2 }' "$artifacts") [[ "$prior_signed_id" == "$signed_id" && "$prior_notes_id" == "$notes_id" ]] || exit 1 - gh run download "$RUN" --repo "$GITHUB_REPOSITORY" --name plugin-distribution \ + gh run download "$source_run" --repo "$GITHUB_REPOSITORY" --name plugin-distribution \ --dir "$RUNNER_TEMP/prior-signed" - gh run download "$RUN" --repo "$GITHUB_REPOSITORY" --name release-notes \ + gh run download "$source_run" --repo "$GITHUB_REPOSITORY" --name release-notes \ --dir "$RUNNER_TEMP/prior-notes" [[ -f "$RUNNER_TEMP/prior-signed/${ARCHIVE##*/}" && ! -L "$RUNNER_TEMP/prior-signed/${ARCHIVE##*/}" ]] || exit 1 [[ -f "$RUNNER_TEMP/prior-notes/release-notes.md" && diff --git a/tests/publish_jetbrains_marketplace/test_marketplace_upload.py b/tests/publish_jetbrains_marketplace/test_marketplace_upload.py index 52a6dd3..3b79bbe 100644 --- a/tests/publish_jetbrains_marketplace/test_marketplace_upload.py +++ b/tests/publish_jetbrains_marketplace/test_marketplace_upload.py @@ -616,6 +616,79 @@ def test_changed_prior_artifact_blocks_retry( assert not httpserver.log +def test_reconciled_attempt_allows_another_retry( + signed_archive: Path, + tmp_path: Path, + marketplace_scripts: Path, + github_api: tuple[HTTPServer, dict[str, str]], + httpserver: HTTPServer, +) -> None: + result = publish_action( + tmp_path, + marketplace_scripts, + github_api, + previous_run=True, + reconciled_attempt=True, + accepted_archive=signed_archive, + ARCHIVE=str(signed_archive), + SHA256=sha256(signed_archive.read_bytes()).hexdigest(), + PLUGIN_ID="29973", + CHANNEL="beta", + PUBLISH_TOKEN="fixture-token", + ) + assert result.returncode == 0, result.stderr + assert not httpserver.log + assert any(request.path.endswith("/artifacts/62/zip") for request, _ in github_api[0].log) + + +def test_receipt_from_unlisted_run_blocks_retry( + signed_archive: Path, + tmp_path: Path, + marketplace_scripts: Path, + github_api: tuple[HTTPServer, dict[str, str]], + httpserver: HTTPServer, +) -> None: + result = publish_action( + tmp_path, + marketplace_scripts, + github_api, + previous_run=True, + accepted_archive=signed_archive, + receipt_changes={"sourceRunId": 30}, + ARCHIVE=str(signed_archive), + SHA256=sha256(signed_archive.read_bytes()).hexdigest(), + PLUGIN_ID="29973", + CHANNEL="beta", + PUBLISH_TOKEN="fixture-token", + ) + assert result.returncode != 0 + assert not httpserver.log + + +def test_fork_run_on_tag_named_branch_allows_upload( + signed_archive: Path, + tmp_path: Path, + marketplace_scripts: Path, + github_api: tuple[HTTPServer, dict[str, str]], + httpserver: HTTPServer, +) -> None: + httpserver.expect_request("/api/updates/upload", method="POST").respond_with_data("{}") + result = publish_action( + tmp_path, + marketplace_scripts, + github_api, + fork_run=True, + ARCHIVE=str(signed_archive), + SHA256=sha256(signed_archive.read_bytes()).hexdigest(), + PLUGIN_ID="29973", + CHANNEL="beta", + PUBLISH_TOKEN="fixture-token", + ) + assert result.returncode == 0, result.stderr + assert len(httpserver.log) == 1 + assert not any("/runs/39/" in request.path for request, _ in github_api[0].log) + + def publish_action( repository: Path, scripts: Path, @@ -646,6 +719,8 @@ def publish_action( prior_notes: bytes | None = None, remote_commit: str = "0" * 40, annotated: bool = False, + fork_run: bool = False, + reconciled_attempt: bool = False, **settings: str, ) -> subprocess.CompletedProcess[str]: server, environment = github_api @@ -662,6 +737,7 @@ def publish_action( "id": 40, "head_branch": "main", "head_sha": "2" * 40, + "head_repository": {"full_name": "tox-dev/fixture"}, "path": ".github/workflows/check.yaml", } ] @@ -674,9 +750,20 @@ def publish_action( "id": 41, "head_branch": "v0.1.11", "head_sha": previous_commit, + "head_repository": {"full_name": "tox-dev/fixture"}, "path": f".github/workflows/{previous_workflow}", } ) + if fork_run: + runs.append( + { + "id": 39, + "head_branch": "v0.1.11", + "head_sha": "3" * 40, + "head_repository": {"full_name": "mallory/fixture"}, + "path": ".github/workflows/release.yaml", + } + ) server.expect_request("/api/v3/repos/tox-dev/fixture/actions/runs").respond_with_json({"workflow_runs": runs}) deployments = [{"id": 71, "sha": "0" * 40}] if extra_current_deployment: @@ -722,7 +809,12 @@ def publish_action( ) if previous_run: server.expect_request("/api/v3/repos/tox-dev/fixture/actions/runs/41/jobs").respond_with_json( - {"jobs": [{"run_attempt": 1, "steps": [{"name": previous_step, "started_at": "2026-10-23T00:00:00Z"}]}]} + { + "jobs": [ + {"run_attempt": attempt, "steps": [{"name": previous_step, "started_at": "2026-10-23T00:00:00Z"}]} + for attempt in ((1, 2) if reconciled_attempt else (1,)) + ] + } ) artifacts = ( [ @@ -737,6 +829,7 @@ def publish_action( } for artifact_id, artifact_name in ( (61, "marketplace-acceptance-41-1"), + *([(62, "marketplace-acceptance-41-2")] if reconciled_attempt else []), (51, "plugin-distribution"), (52, "release-notes"), ) @@ -766,6 +859,7 @@ def publish_action( receipt.update(receipt_changes or {}) for artifact_id, filename, payload in ( (61, "other.json" if missing_receipt else "marketplace-acceptance.json", json.dumps(receipt).encode()), + (62, "marketplace-acceptance.json", json.dumps(receipt).encode()), ( 51, accepted_archive.name, From 174dfc529ecbdb4f7816d4dbdef715dabb21e235 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sat, 24 Oct 2026 23:12:32 -0700 Subject: [PATCH 06/44] test(marketplace): cover unidentified deployments --- .../test_marketplace_upload.py | 43 ++++++++++++++++++- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/tests/publish_jetbrains_marketplace/test_marketplace_upload.py b/tests/publish_jetbrains_marketplace/test_marketplace_upload.py index 3b79bbe..45c2699 100644 --- a/tests/publish_jetbrains_marketplace/test_marketplace_upload.py +++ b/tests/publish_jetbrains_marketplace/test_marketplace_upload.py @@ -269,6 +269,41 @@ def test_deleted_prior_run_deployment_blocks_upload( assert not httpserver.log +@pytest.mark.parametrize( + ("log_url", "job_run"), + [ + pytest.param("https://example.org/tox-dev/fixture/actions/runs/41/job/170", None, id="foreign-host"), + pytest.param("https://github.com/tox-dev/fixture/actions/runs/41/attempts/1", None, id="not-a-job"), + pytest.param(None, 40, id="job-from-another-run"), + ], +) +def test_unidentified_prior_deployment_blocks_upload( + signed_archive: Path, + tmp_path: Path, + marketplace_scripts: Path, + github_api: tuple[HTTPServer, dict[str, str]], + httpserver: HTTPServer, + log_url: str | None, + job_run: int | None, +) -> None: + result = publish_action( + tmp_path, + marketplace_scripts, + github_api, + previous_deployment=True, + previous_deployment_log_url=log_url, + previous_deployment_job_run=job_run, + ARCHIVE=str(signed_archive), + SHA256=sha256(signed_archive.read_bytes()).hexdigest(), + PLUGIN_ID="29973", + CHANNEL="beta", + PUBLISH_TOKEN="fixture-token", + ) + assert result.returncode != 0 + assert "Cannot identify release deployment 70" in result.stderr + assert not httpserver.log + + def test_prior_contributor_deployment_blocks_upload( signed_archive: Path, tmp_path: Path, @@ -708,6 +743,8 @@ def publish_action( previous_deployment_state: str = "success", previous_deployment_sha: str = "0" * 40, previous_deployment_job_missing: bool = False, + previous_deployment_log_url: str | None = None, + previous_deployment_job_run: int | None = None, extra_current_deployment: bool = False, current_deployment_upload: bool = True, current_attempt: int = 1, @@ -780,7 +817,9 @@ def publish_action( server.expect_request(f"/api/v3/repos/tox-dev/fixture/deployments/{deployment_id}/statuses").respond_with_json( [ { - "log_url": f"https://github.com/tox-dev/fixture/actions/runs/{run_id}/job/{job_id}", + "log_url": previous_deployment_log_url + if prior and previous_deployment_log_url is not None + else f"https://github.com/tox-dev/fixture/actions/runs/{run_id}/job/{job_id}", "state": previous_deployment_state if prior else "in_progress", } ] @@ -791,7 +830,7 @@ def publish_action( else: job_request.respond_with_json( { - "run_id": run_id, + "run_id": previous_deployment_job_run if prior and previous_deployment_job_run else run_id, "run_attempt": attempt, "steps": [ { From 8c6a25a8067dd322dc57e9eb658c8165eaeded3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sat, 24 Oct 2026 23:13:44 -0700 Subject: [PATCH 07/44] fix(mise): check the binary digest before it runs --- changelog.d/+mise-digest-first.security.md | 2 + refresh-mise-pin/action.yml | 16 ++++++ tests/refresh_mise_pin/test_refresh.py | 57 ++++++++++++++-------- 3 files changed, 54 insertions(+), 21 deletions(-) create mode 100644 changelog.d/+mise-digest-first.security.md diff --git a/changelog.d/+mise-digest-first.security.md b/changelog.d/+mise-digest-first.security.md new file mode 100644 index 0000000..f987181 --- /dev/null +++ b/changelog.d/+mise-digest-first.security.md @@ -0,0 +1,2 @@ +Check the downloaded mise executable against the seven-day-old release digest before mise-action runs it, and run its +version listing outside the caller's checkout. diff --git a/refresh-mise-pin/action.yml b/refresh-mise-pin/action.yml index 9a9e352..f71b4dc 100644 --- a/refresh-mise-pin/action.yml +++ b/refresh-mise-pin/action.yml @@ -47,11 +47,27 @@ runs: uv run --no-project --python "$RUNNER_TEMP/mise-editor" python \ "$GITHUB_ACTION_PATH/../tools/refresh_mise_pin.py" select \ "$RUNNER_TEMP/mise-releases.jsonl" "$GITHUB_OUTPUT" + - name: Download the selected mise executable + shell: bash + env: + GH_TOKEN: ${{ inputs.github-token }} + VERSION: ${{ steps.select.outputs.version }} + EXPECTED_SHA256: ${{ steps.select.outputs.sha256 }} + run: | + set -euo pipefail + asset="mise-v$VERSION-linux-x64" + mkdir -p "$RUNNER_TEMP/mise-download" "$RUNNER_TEMP/refresh-mise-pin/bin" "$RUNNER_TEMP/refresh-mise-pin-cwd" + gh release download "v$VERSION" --repo jdx/mise --pattern "$asset" --dir "$RUNNER_TEMP/mise-download" + printf '%s %s\n' "$EXPECTED_SHA256" "$RUNNER_TEMP/mise-download/$asset" | sha256sum --check --strict + install -m 0755 "$RUNNER_TEMP/mise-download/$asset" "$RUNNER_TEMP/refresh-mise-pin/bin/mise" + printf '%s' "$VERSION" >"$RUNNER_TEMP/refresh-mise-pin/bin/mise-version" + # mise-action checks an existing binary against the signed checksums before it runs it. - name: Install signature-verified mise uses: jdx/mise-action@08ae2a091c038e1e05ec6f5115dc5c69d5a2a6b8 # v5.1.2 with: version: ${{ steps.select.outputs.version }} mise_dir: ${{ runner.temp }}/refresh-mise-pin + working_directory: ${{ runner.temp }}/refresh-mise-pin-cwd cache: "false" install: "false" env: "false" diff --git a/tests/refresh_mise_pin/test_refresh.py b/tests/refresh_mise_pin/test_refresh.py index 1cada0c..ff2a024 100644 --- a/tests/refresh_mise_pin/test_refresh.py +++ b/tests/refresh_mise_pin/test_refresh.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import os import subprocess import sys from datetime import UTC, datetime, timedelta @@ -414,28 +415,42 @@ def test_main_selects_release(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) - assert (tmp_path / "output").read_text() == f"version=2026.10.13\nsha256={'a' * 64}\n" -def test_action_verifies_signed_checksum_and_exports_mise_path() -> None: - action = yaml.safe_load((Path(__file__).resolve().parents[2] / "refresh-mise-pin" / "action.yml").read_text()) - steps = action["runs"]["steps"] - installer_index = next( - index for index, step in enumerate(steps) if step.get("uses", "").startswith("jdx/mise-action@") - ) - assert installer_index < next( - index for index, step in enumerate(steps) if step["name"] == "Update the workflow pin" - ) - assert steps[installer_index] == { - "name": "Install signature-verified mise", - "uses": "jdx/mise-action@08ae2a091c038e1e05ec6f5115dc5c69d5a2a6b8", - "with": { - "version": "${{ steps.select.outputs.version }}", - "mise_dir": "${{ runner.temp }}/refresh-mise-pin", - "cache": "false", - "install": "false", - "env": "false", - "export_path": "false", - "add_shims_to_path": "false", +@pytest.mark.parametrize( + ("published", "accepted"), + [pytest.param(b"verified mise", True, id="selected-digest"), pytest.param(b"swapped", False, id="other-bytes")], +) +def test_action_checks_executable_digest_before_mise_runs(tmp_path: Path, published: bytes, accepted: bool) -> None: + steps = yaml.safe_load((Path(__file__).resolve().parents[2] / "refresh-mise-pin" / "action.yml").read_text())[ + "runs" + ]["steps"] + download = next(step for step in steps if step.get("name") == "Download the selected mise executable") + installer = next(step for step in steps if step.get("uses", "").startswith("jdx/mise-action@")) + assert steps.index(download) < steps.index(installer) + fake_bin = tmp_path / "fake-bin" + fake_bin.mkdir() + (tmp_path / "published").write_bytes(published) + gh = fake_bin / "gh" + gh.write_text(f'#!/bin/sh\ncp {tmp_path / "published"} "$9/mise-v2026.10.13-linux-x64"\n') + gh.chmod(0o755) + result = subprocess.run( + ["bash", "-e", "-o", "pipefail", "-c", download["run"]], + env=os.environ + | { + "PATH": f"{fake_bin}:{os.environ['PATH']}", + "RUNNER_TEMP": str(tmp_path), + "VERSION": "2026.10.13", + "EXPECTED_SHA256": sha256(b"verified mise").hexdigest(), }, - } + text=True, + capture_output=True, + check=False, + ) + binary = tmp_path / "refresh-mise-pin" / "bin" / "mise" + assert (result.returncode == 0) is accepted, result.stderr + assert binary.exists() is accepted + if accepted: + assert os.access(binary, os.X_OK) + assert binary.with_name("mise-version").read_text() == "2026.10.13" def test_action_keeps_linux_executable_and_archives_in_release_listing() -> None: From f7668af59b9349c5b1b8684d59977c0569177ad8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sat, 24 Oct 2026 23:13:44 -0700 Subject: [PATCH 08/44] fix(mise): keep line endings and reject null digests --- changelog.d/+mise-line-endings.bugfix.md | 2 ++ tests/refresh_mise_pin/test_refresh.py | 23 ++++++++++++++++++++++- tools/refresh_mise_pin.py | 8 +++++--- 3 files changed, 29 insertions(+), 4 deletions(-) create mode 100644 changelog.d/+mise-line-endings.bugfix.md diff --git a/changelog.d/+mise-line-endings.bugfix.md b/changelog.d/+mise-line-endings.bugfix.md new file mode 100644 index 0000000..8205584 --- /dev/null +++ b/changelog.d/+mise-line-endings.bugfix.md @@ -0,0 +1,2 @@ +Keep CRLF line endings when refreshing a mise pin, and report a release asset without a digest as an error instead +of a traceback. diff --git a/tests/refresh_mise_pin/test_refresh.py b/tests/refresh_mise_pin/test_refresh.py index ff2a024..2123599 100644 --- a/tests/refresh_mise_pin/test_refresh.py +++ b/tests/refresh_mise_pin/test_refresh.py @@ -22,7 +22,7 @@ def _release( prerelease: bool = False, draft: bool = False, asset: bool = True, - digest: str = "sha256:" + "a" * 64, + digest: str | None = "sha256:" + "a" * 64, asset_days: int | None = None, archive: bool = True, recent_archive: str = "", @@ -453,6 +453,26 @@ def test_action_checks_executable_digest_before_mise_runs(tmp_path: Path, publis assert binary.with_name("mise-version").read_text() == "2026.10.13" +def test_update_keeps_crlf_line_endings(tmp_path: Path, mise_binary: tuple[Path, str]) -> None: + binary, digest = mise_binary + workflow = tmp_path / ".github" / "workflows" / "release.yaml" + workflow.parent.mkdir(parents=True) + workflow.write_bytes(("jobs:\n build:\n steps:\n" + _step("2026.10.12", "0" * 64)).replace("\n", "\r\n").encode()) + result = _run( + tmp_path, + "update", + ".github/workflows/release.yaml", + "🔧 Set up mise (JDKs)", + "2026.10.13", + digest, + str(binary), + ) + assert result.returncode == 0, result.stderr + assert workflow.read_bytes() == ( + ("jobs:\n build:\n steps:\n" + _step("2026.10.13", digest)).replace("\n", "\r\n").encode() + ) + + def test_action_keeps_linux_executable_and_archives_in_release_listing() -> None: action = yaml.safe_load((Path(__file__).resolve().parents[2] / "refresh-mise-pin" / "action.yml").read_text()) query = action["runs"]["steps"][0]["run"].split("--jq '", 1)[1].split("'", 1)[0] @@ -478,6 +498,7 @@ def test_action_keeps_linux_executable_and_archives_in_release_listing() -> None pytest.param([_release("2026.10.13", days=8, asset=False)], "no unique Linux", id="missing-asset"), pytest.param([_release("2026.10.13", days=8, archive=False)], "no unique Linux", id="missing-archive"), pytest.param([_release("2026.10.13", days=8, digest="sha256:bad")], "no unique Linux", id="bad-asset-digest"), + pytest.param([_release("2026.10.13", days=8, digest=None)], "no unique Linux", id="null-asset-digest"), ], ) def test_select_rejects_invalid_release(tmp_path: Path, releases: list[dict[str, object]], message: str) -> None: diff --git a/tools/refresh_mise_pin.py b/tools/refresh_mise_pin.py index f02527e..156afb1 100644 --- a/tools/refresh_mise_pin.py +++ b/tools/refresh_mise_pin.py @@ -18,7 +18,7 @@ class _Asset(TypedDict): name: str - digest: str + digest: str | None created_at: str updated_at: str @@ -91,6 +91,7 @@ def _select_release(path: Path) -> tuple[str, str]: matches = [asset for asset in release["assets"] if asset["name"] == name] if ( len(matches) != 1 + or not isinstance(matches[0]["digest"], str) or not matches[0]["digest"].startswith("sha256:") or not _hex(matches[0]["digest"][7:], 64) ): @@ -117,7 +118,8 @@ def _update_pin(workflow: Path, step_name: str, version: str, expected_sha256: s with binary.open("rb") as stream: if file_digest(stream, "sha256").hexdigest() != expected_sha256: raise ValueError("verified mise binary differs from the GitHub release asset digest") - source = workflow.read_text() + with workflow.open(newline="") as stream: + source = stream.read() root = yaml.compose(source) if not isinstance(root, MappingNode): raise TypeError("workflow needs a jobs mapping") @@ -181,7 +183,7 @@ def _update_pin(workflow: Path, step_name: str, version: str, expected_sha256: s return False descriptor, name = tempfile.mkstemp(dir=workflow.parent, prefix=f".{workflow.name}.") try: - with os.fdopen(descriptor, "w") as stream: + with os.fdopen(descriptor, "w", newline="") as stream: stream.write(updated) os.chmod(name, stat.S_IMODE(workflow.stat().st_mode)) os.replace(name, workflow) From 055984f6366b3bdddfb2a3072251c80d059f10e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sat, 24 Oct 2026 23:14:18 -0700 Subject: [PATCH 09/44] fix(lock): require normalized dependency groups --- .../+normalized-lock-groups.security.md | 2 ++ tests/install_locked_tools/test_metadata.py | 27 +++++++++++++++++++ tools/check_lock_groups.py | 4 +++ 3 files changed, 33 insertions(+) create mode 100644 changelog.d/+normalized-lock-groups.security.md diff --git a/changelog.d/+normalized-lock-groups.security.md b/changelog.d/+normalized-lock-groups.security.md new file mode 100644 index 0000000..1fce646 --- /dev/null +++ b/changelog.d/+normalized-lock-groups.security.md @@ -0,0 +1,2 @@ +Reject dependency group names that are not normalized, in the lock and in the selected groups. A name such as +`project_runtime` matched no lock marker, so its packages skipped the cooling and PyPI checks. diff --git a/tests/install_locked_tools/test_metadata.py b/tests/install_locked_tools/test_metadata.py index 98ec875..82e40b9 100644 --- a/tests/install_locked_tools/test_metadata.py +++ b/tests/install_locked_tools/test_metadata.py @@ -525,6 +525,33 @@ def test_lock_metadata_rejects_unapproved_indexes( assert "index" in capsys.readouterr().err +@pytest.mark.parametrize( + ("change", "group"), + [ + pytest.param("lock", "release-check", id="lock-group"), + pytest.param("argument", "Release-Check", id="selected-group"), + ], +) +def test_lock_metadata_rejects_unnormalized_group( + lock_case: tuple[Path, Path], + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + change: str, + group: str, +) -> None: + lock = lock_case[0] + if change == "lock": + lock.write_text(lock.read_text().replace('dependency-groups = ["release-check"]', 'dependency-groups = ["release-check", "project_runtime"]')) + source = Path(__file__).resolve().parents[2] + monkeypatch.syspath_prepend(str(source / "tools")) + monkeypatch.setattr(sys, "argv", ["check_lock_groups.py", str(lock), group]) + + with pytest.raises(SystemExit, match="2"): + runpy.run_path(str(source / "tools/check_lock_groups.py"), run_name="__main__") + + assert "dependency group names must be normalized" in capsys.readouterr().err + + def _run_check(lock_case: tuple[Path, Path], monkeypatch: pytest.MonkeyPatch, *options: str) -> None: source = Path(__file__).resolve().parents[2] monkeypatch.syspath_prepend(str(source / "tools")) diff --git a/tools/check_lock_groups.py b/tools/check_lock_groups.py index 9568547..96bed50 100644 --- a/tools/check_lock_groups.py +++ b/tools/check_lock_groups.py @@ -44,6 +44,10 @@ def main() -> None: parser.error("at least one dependency group is required") with args.lock.open("rb") as stream: lock = Pylock.from_dict(tomllib.load(stream)) + if unnormalized := sorted( + name for name in {*groups, *(lock.dependency_groups or ())} if canonicalize_name(name) != name + ): + raise ValueError(f"dependency group names must be normalized: {', '.join(unnormalized)}") if args.project: if (args.project.parent / "nab.toml").exists(): raise ValueError("project nab.toml can override the reviewed index policy") From 172cad69a1e63e4b6ed4597c3770c9971ef0e153 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sat, 24 Oct 2026 23:14:18 -0700 Subject: [PATCH 10/44] fix(lock): report a non-table producer setting --- changelog.d/+lock-producer-table.bugfix.md | 1 + tests/install_locked_tools/test_metadata.py | 12 ++++++++++++ tools/check_lock_groups.py | 7 ++++--- 3 files changed, 17 insertions(+), 3 deletions(-) create mode 100644 changelog.d/+lock-producer-table.bugfix.md diff --git a/changelog.d/+lock-producer-table.bugfix.md b/changelog.d/+lock-producer-table.bugfix.md new file mode 100644 index 0000000..a4e0008 --- /dev/null +++ b/changelog.d/+lock-producer-table.bugfix.md @@ -0,0 +1 @@ +Report a lock whose `tool.release-creator` or `tool.nab` value is not a table as an error instead of a traceback. diff --git a/tests/install_locked_tools/test_metadata.py b/tests/install_locked_tools/test_metadata.py index 82e40b9..c487c2e 100644 --- a/tests/install_locked_tools/test_metadata.py +++ b/tests/install_locked_tools/test_metadata.py @@ -552,6 +552,18 @@ def test_lock_metadata_rejects_unnormalized_group( assert "dependency group names must be normalized" in capsys.readouterr().err +def test_lock_metadata_rejects_non_table_producer( + lock_case: tuple[Path, Path], monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + lock = lock_case[0] + lock.write_text(lock.read_text().replace("[tool.nab]\ncreated-at = 2026-10-20T00:00:00Z\n", '[tool]\nnab = "oops"\n')) + + with pytest.raises(SystemExit, match="2"): + _run_check(lock_case, monkeypatch) + + assert "lock producer settings must be a table" in capsys.readouterr().err + + def _run_check(lock_case: tuple[Path, Path], monkeypatch: pytest.MonkeyPatch, *options: str) -> None: source = Path(__file__).resolve().parents[2] monkeypatch.syspath_prepend(str(source / "tools")) diff --git a/tools/check_lock_groups.py b/tools/check_lock_groups.py index 96bed50..5c4ee07 100644 --- a/tools/check_lock_groups.py +++ b/tools/check_lock_groups.py @@ -79,9 +79,10 @@ def main() -> None: def _check_cooling(lock: Pylock, groups: set[str], *, local_tool_source: bool = False) -> None: - created = ( - (lock.tool or {}).get("nab" if lock_provider(lock.tool) == "nab" else "release-creator", {}).get("created-at") - ) + producer = (lock.tool or {}).get(lock_provider(lock.tool)) + if not isinstance(producer, dict): + raise TypeError("lock producer settings must be a table") + created = producer.get("created-at") if not isinstance(created, datetime) or created.tzinfo is None or created > datetime.now(tz=UTC): raise ValueError("lock has no valid creation time or was created in the future") threshold = created - timedelta(days=7) From c0e3a4e20444295fdbe53199db0c1155b125ac72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sat, 24 Oct 2026 23:15:32 -0700 Subject: [PATCH 11/44] style: format round 13 fixes --- changelog.d/+marketplace-retry-receipts.bugfix.md | 4 ++-- changelog.d/+mise-line-endings.bugfix.md | 4 ++-- tests/check_hook_update_cooldown/test_frozen_hooks.py | 4 +++- tests/install_locked_tools/test_metadata.py | 10 ++++++++-- tests/refresh_mise_pin/test_refresh.py | 4 +++- 5 files changed, 18 insertions(+), 8 deletions(-) diff --git a/changelog.d/+marketplace-retry-receipts.bugfix.md b/changelog.d/+marketplace-retry-receipts.bugfix.md index 01db312..fa28dff 100644 --- a/changelog.d/+marketplace-retry-receipts.bugfix.md +++ b/changelog.d/+marketplace-retry-receipts.bugfix.md @@ -1,3 +1,3 @@ Let a JetBrains Marketplace retry follow an attempt that reconciled an earlier upload. That attempt now keeps a copy of -the receipt it verified, and the check reads the artifacts of the run named in the receipt. Fork pull request runs on -a branch named like the tag no longer block the upload. +the receipt it verified, and the check reads the artifacts of the run named in the receipt. Fork pull request runs on a +branch named like the tag no longer block the upload. diff --git a/changelog.d/+mise-line-endings.bugfix.md b/changelog.d/+mise-line-endings.bugfix.md index 8205584..7188feb 100644 --- a/changelog.d/+mise-line-endings.bugfix.md +++ b/changelog.d/+mise-line-endings.bugfix.md @@ -1,2 +1,2 @@ -Keep CRLF line endings when refreshing a mise pin, and report a release asset without a digest as an error instead -of a traceback. +Keep CRLF line endings when refreshing a mise pin, and report a release asset without a digest as an error instead of a +traceback. diff --git a/tests/check_hook_update_cooldown/test_frozen_hooks.py b/tests/check_hook_update_cooldown/test_frozen_hooks.py index d60ab93..25cfaa6 100644 --- a/tests/check_hook_update_cooldown/test_frozen_hooks.py +++ b/tests/check_hook_update_cooldown/test_frozen_hooks.py @@ -36,7 +36,9 @@ def test_frozen_hook_revision(tmp_path: Path, revision: str, accepted: bool) -> "hooks", [ pytest.param("[]", id="no-hooks"), - pytest.param("[{id: lint, language: python, additional_dependencies: [ruff==0.1.0]}]", id="python-dependencies"), + pytest.param( + "[{id: lint, language: python, additional_dependencies: [ruff==0.1.0]}]", id="python-dependencies" + ), pytest.param("[{id: lint, language: node}]", id="node-without-dependencies"), ], ) diff --git a/tests/install_locked_tools/test_metadata.py b/tests/install_locked_tools/test_metadata.py index c487c2e..ae4fe07 100644 --- a/tests/install_locked_tools/test_metadata.py +++ b/tests/install_locked_tools/test_metadata.py @@ -541,7 +541,11 @@ def test_lock_metadata_rejects_unnormalized_group( ) -> None: lock = lock_case[0] if change == "lock": - lock.write_text(lock.read_text().replace('dependency-groups = ["release-check"]', 'dependency-groups = ["release-check", "project_runtime"]')) + lock.write_text( + lock.read_text().replace( + 'dependency-groups = ["release-check"]', 'dependency-groups = ["release-check", "project_runtime"]' + ) + ) source = Path(__file__).resolve().parents[2] monkeypatch.syspath_prepend(str(source / "tools")) monkeypatch.setattr(sys, "argv", ["check_lock_groups.py", str(lock), group]) @@ -556,7 +560,9 @@ def test_lock_metadata_rejects_non_table_producer( lock_case: tuple[Path, Path], monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: lock = lock_case[0] - lock.write_text(lock.read_text().replace("[tool.nab]\ncreated-at = 2026-10-20T00:00:00Z\n", '[tool]\nnab = "oops"\n')) + lock.write_text( + lock.read_text().replace("[tool.nab]\ncreated-at = 2026-10-20T00:00:00Z\n", '[tool]\nnab = "oops"\n') + ) with pytest.raises(SystemExit, match="2"): _run_check(lock_case, monkeypatch) diff --git a/tests/refresh_mise_pin/test_refresh.py b/tests/refresh_mise_pin/test_refresh.py index 2123599..2dfd9a4 100644 --- a/tests/refresh_mise_pin/test_refresh.py +++ b/tests/refresh_mise_pin/test_refresh.py @@ -457,7 +457,9 @@ def test_update_keeps_crlf_line_endings(tmp_path: Path, mise_binary: tuple[Path, binary, digest = mise_binary workflow = tmp_path / ".github" / "workflows" / "release.yaml" workflow.parent.mkdir(parents=True) - workflow.write_bytes(("jobs:\n build:\n steps:\n" + _step("2026.10.12", "0" * 64)).replace("\n", "\r\n").encode()) + workflow.write_bytes( + ("jobs:\n build:\n steps:\n" + _step("2026.10.12", "0" * 64)).replace("\n", "\r\n").encode() + ) result = _run( tmp_path, "update", From 26921722020b7ee384f76fd580cde342aec89b42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sat, 24 Oct 2026 23:15:32 -0700 Subject: [PATCH 12/44] fix(hooks): let humans add cooled managed pins --- changelog.d/+human-hook-pins.bugfix.md | 2 + .../test_hook_config.py | 115 ++++++++++-------- tools/check_hook_config.py | 7 +- 3 files changed, 69 insertions(+), 55 deletions(-) create mode 100644 changelog.d/+human-hook-pins.bugfix.md diff --git a/changelog.d/+human-hook-pins.bugfix.md b/changelog.d/+human-hook-pins.bugfix.md new file mode 100644 index 0000000..1a14f9f --- /dev/null +++ b/changelog.d/+human-hook-pins.bugfix.md @@ -0,0 +1,2 @@ +Let a human pull request add or change managed hook dependency pins when each new pin is an exact, cooled PyPI release +and the complete closure check passes. Adding a Python hook with dependencies no longer needs the release App. diff --git a/tests/check_hook_update_cooldown/test_hook_config.py b/tests/check_hook_update_cooldown/test_hook_config.py index d4310e5..ba82092 100644 --- a/tests/check_hook_update_cooldown/test_hook_config.py +++ b/tests/check_hook_update_cooldown/test_hook_config.py @@ -2,15 +2,13 @@ import json import runpy -import subprocess -import sys from collections.abc import Callable from datetime import UTC, datetime, timedelta from io import BytesIO from pathlib import Path from typing import cast from unittest.mock import MagicMock -from urllib.request import urlopen +from urllib.request import Request, urlopen import pytest from pytest_mock import MockerFixture @@ -191,7 +189,18 @@ def test_hook_config_rejects_human_change_without_repositories( check_config(base, proposed, False, 7, human=True) -def test_human_cannot_change_managed_pin(configs: tuple[Path, Path], check_config: Callable[..., None]) -> None: +@pytest.mark.parametrize( + ("uploaded_days", "accepted"), + [pytest.param(10, True, id="cooled"), pytest.param(1, False, id="recent")], +) +def test_human_managed_pin_change_needs_cooled_pypi_pin( + configs: tuple[Path, Path], + check_config: Callable[..., None], + pypi_file: dict[str, object], + pypi_request: MagicMock, + uploaded_days: int, + accepted: bool, +) -> None: base, proposed = configs base.write_text( base.read_text().replace( @@ -199,33 +208,41 @@ def test_human_cannot_change_managed_pin(configs: tuple[Path, Path], check_confi ) ) proposed.write_text(base.read_text().replace("example==1.0.0", "example==1.2.0")) + pypi_file["upload_time_iso_8601"] = (datetime.now(UTC) - timedelta(days=uploaded_days)).isoformat() + pypi_request.return_value = _response("example", pypi_file) - with pytest.raises(ValueError, match="managed hook dependencies require the release App"): + if accepted: check_config(base, proposed, False, 7, human=True) + else: + with pytest.raises(ValueError, match="uncooled or untrusted PyPI artifact"): + check_config(base, proposed, False, 7, human=True) + assert pypi_request.call_args.args[0].full_url == "https://pypi.org/pypi/example/1.2.0/json" -@pytest.mark.parametrize( - ("proposed_hooks", "accepted"), - [ - pytest.param( - " - id: second\n - id: first\n additional_dependencies:\n" - ' - "other==1.2.0" # release-creator managed\n', - True, - id="reorder-same-owner", - ), - pytest.param( - " - id: first\n - id: second\n additional_dependencies:\n" - ' - "other==1.2.0" # release-creator managed\n', - False, - id="relocate-pin", - ), - ], -) -def test_human_managed_pin_stays_with_its_hook( - configs: tuple[Path, Path], - check_config: Callable[..., None], - proposed_hooks: str, - accepted: bool, +def test_human_can_add_python_hook_next_to_managed_pins( + configs: tuple[Path, Path], check_config: Callable[..., None], pypi_request: MagicMock +) -> None: + base, proposed = configs + prefix = "repos:\n - repo: https://example.org/hook\n rev: old\n hooks:\n" + first = ( + ' - id: first\n additional_dependencies:\n - "other==1.2.0" # release-creator managed\n' + ) + base.write_text(prefix + first) + proposed.write_text( + prefix + + first + + ' - id: second\n additional_dependencies:\n - "example==1.2.0" # release-creator managed\n' + ) + + check_config(base, proposed, False, 7, human=True) + + assert [call.args[0].full_url for call in pypi_request.call_args_list] == [ + "https://pypi.org/pypi/example/1.2.0/json" + ] + + +def test_human_keeps_existing_managed_pins_without_pypi( + configs: tuple[Path, Path], check_config: Callable[..., None], pypi_request: MagicMock ) -> None: base, proposed = configs prefix = "repos:\n - repo: https://example.org/hook\n rev: old\n hooks:\n" @@ -235,13 +252,15 @@ def test_human_managed_pin_stays_with_its_hook( + ' - "other==1.2.0" # release-creator managed\n' + " - id: second\n" ) - proposed.write_text(prefix + proposed_hooks) + proposed.write_text( + prefix + + " - id: second\n - id: first\n additional_dependencies:\n" + + ' - "other==1.2.0" # release-creator managed\n' + ) - if accepted: - check_config(base, proposed, False, 7, human=True) - else: - with pytest.raises(ValueError, match="managed hook dependencies require the release App"): - check_config(base, proposed, False, 7, human=True) + check_config(base, proposed, False, 7, human=True) + + pypi_request.assert_not_called() @pytest.mark.parametrize( @@ -293,27 +312,21 @@ def test_human_bootstrap_rejects_ambiguous_hook_identity( check_config(base, proposed, False, 7, human=True) -def test_human_can_bootstrap_managed_pins_from_release_migration() -> None: +def test_human_can_bootstrap_managed_pins_from_release_migration( + check_config: Callable[..., None], pypi_file: dict[str, object], pypi_request: MagicMock +) -> None: + def listing(request: Request, **_: object) -> BytesIO: + name, version = request.full_url.removeprefix("https://pypi.org/pypi/").removesuffix("/json").split("/") + return BytesIO(json.dumps({"info": {"name": name, "version": version}, "urls": [pypi_file]}).encode()) + + pypi_request.side_effect = listing root = Path(__file__).resolve().parents[2] - result = subprocess.run( - [ - sys.executable, - str(root / "tools/check_hook_config.py"), - str(Path(__file__).parent / "fixtures/pre-bootstrap.yaml"), - str(root / ".pre-commit-config.yaml"), - "--managed", - "", - "--human", - "true", - "--minimum-days", - "7", - ], - capture_output=True, - text=True, - check=False, + + check_config( + Path(__file__).parent / "fixtures/pre-bootstrap.yaml", root / ".pre-commit-config.yaml", False, 7, human=True ) - assert result.returncode == 0, result.stderr + assert pypi_request.call_count > 0 def test_app_update_keeps_direct_range( diff --git a/tools/check_hook_config.py b/tools/check_hook_config.py index 608cdc2..5844bc0 100644 --- a/tools/check_hook_config.py +++ b/tools/check_hook_config.py @@ -61,10 +61,9 @@ def check_config(base: Path, proposed: Path, managed: bool, minimum_days: int, * if human: if not isinstance(old.get("repos"), list) or not isinstance(new.get("repos"), list): raise TypeError("hook repositories must be lists") - old_managed = _managed_pin_locations(base_source, old) - new_managed = _managed_pin_locations(proposed_source, new) - if old_managed and old_managed != new_managed: - raise ValueError("managed hook dependencies require the release App") + added = _managed_pin_locations(proposed_source, new) - _managed_pin_locations(base_source, old) + for _, _, item in added: + _check_pypi(Requirement(item), minimum_days) return old_content = deepcopy(old) new_content = deepcopy(new) From a20ac7273ce415e3683473f05e9ab4878d304aed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sat, 24 Oct 2026 23:17:03 -0700 Subject: [PATCH 13/44] fix(lock): accept the upgraded resolver pin --- changelog.d/+resolver-upgrade-patch.bugfix.md | 2 ++ .../test_resolver_upgrade.py | 36 +++++++++++++++++++ tools/verify_cutoff_patch.py | 22 ++++++++++++ 3 files changed, 60 insertions(+) create mode 100644 changelog.d/+resolver-upgrade-patch.bugfix.md diff --git a/changelog.d/+resolver-upgrade-patch.bugfix.md b/changelog.d/+resolver-upgrade-patch.bugfix.md new file mode 100644 index 0000000..c6f4ccb --- /dev/null +++ b/changelog.d/+resolver-upgrade-patch.bugfix.md @@ -0,0 +1,2 @@ +Let the weekly github-workflows update publish a new uv resolver. The writer now accepts a change of the exact +`release-resolver` uv pin when the refreshed lock selects that version. diff --git a/tests/refresh_release_lock/test_resolver_upgrade.py b/tests/refresh_release_lock/test_resolver_upgrade.py index c1cc0c6..8900422 100644 --- a/tests/refresh_release_lock/test_resolver_upgrade.py +++ b/tests/refresh_release_lock/test_resolver_upgrade.py @@ -46,6 +46,42 @@ def test_resolver_upgrade_rejects_invalid_state( assert error in result.stderr +@pytest.mark.parametrize( + ("repository", "pin", "locked", "accepted"), + [ + pytest.param("tox-dev/github-workflows", "uv==0.13.6", "0.13.6", True, id="shared-resolver"), + pytest.param("tox-dev/github-workflows", "uv==0.13.6", "0.13.5", False, id="lock-mismatch"), + pytest.param("tox-dev/github-workflows", "uv>=0.13.6", "0.13.6", False, id="range"), + pytest.param("tox-dev/other", "uv==0.13.6", "0.13.6", False, id="other-repository"), + ], +) +def test_writer_accepts_only_the_locked_resolver_pin( + tmp_path: Path, repository: str, pin: str, locked: str, accepted: bool +) -> None: + project = tmp_path / "pyproject.toml" + project.write_text('[dependency-groups]\nrelease-resolver = ["uv==0.13.5"]\n[tool.release-creator.locks]\n') + subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True) + subprocess.run(["git", "add", "pyproject.toml"], cwd=tmp_path, check=True) + subprocess.run( + ["git", "-c", "user.name=Test", "-c", "user.email=test@example.org", "commit", "-qm", "Pin"], + cwd=tmp_path, + check=True, + ) + project.write_text(project.read_text().replace("uv==0.13.5", pin)) + (tmp_path / "pylock.toml").write_text('dependency-groups = ["release-resolver"]\n' + _uv_package(locked)) + + result = subprocess.run( + [sys.executable, str(Path(__file__).resolve().parents[2] / "tools/verify_cutoff_patch.py")], + cwd=tmp_path, + env=os.environ | {"GITHUB_REPOSITORY": repository}, + text=True, + capture_output=True, + check=False, + ) + + assert (result.returncode == 0) is accepted, result.stderr + + def test_resolver_upgrade_import_has_no_side_effect() -> None: source = Path(__file__).resolve().parents[2] / "tools/refresh_uv_resolver.py" diff --git a/tools/verify_cutoff_patch.py b/tools/verify_cutoff_patch.py index ee0d554..6a3799e 100644 --- a/tools/verify_cutoff_patch.py +++ b/tools/verify_cutoff_patch.py @@ -3,6 +3,8 @@ from __future__ import annotations import argparse +import os +import re import subprocess import tomllib from datetime import UTC, datetime, timedelta @@ -43,6 +45,8 @@ def main() -> None: removed_names = {name for name in names if name not in after.get("exclude-newer-package", {})} if removed_names: remove_release_creator_cutoffs(before, removed_names) + if os.environ.get("GITHUB_REPOSITORY") == "tox-dev/github-workflows": + _accept_resolver_pin(original, actual, args.project.with_name("pylock.toml")) if original != actual: producer = "package" if "release-creator" in original["tool"] else "Nab" parser.error(f"pyproject.toml has changes beyond expired {producer} cutoffs") @@ -50,6 +54,24 @@ def main() -> None: parser.error(str(error)) +def _accept_resolver_pin(original: dict, actual: dict, lock: Path) -> None: + before = original.get("dependency-groups", {}).get("release-resolver") + after = actual.get("dependency-groups", {}).get("release-resolver") + if before == after or not all( + isinstance(pins, list) and len(pins) == 1 and isinstance(pins[0], str) and re.fullmatch(r"uv==[0-9.]+", pins[0]) + for pins in (before, after) + ): + return + version = after[0].removeprefix("uv==") + resolver = '"release-resolver" in dependency_groups' + if not any( + package.get("name") == "uv" and package.get("version") == version and resolver in package.get("marker", "") + for package in tomllib.loads(lock.read_text())["packages"] + ): + raise ValueError(f"pylock.toml does not lock the updated resolver uv {version}") + original["dependency-groups"]["release-resolver"] = after + + if __name__ == "__main__": main() From dfb0635f8ce5d2b6f3ae50846a87a2a0d1ac0063 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sat, 24 Oct 2026 23:17:03 -0700 Subject: [PATCH 14/44] fix(lock): pin uv from the resolver group only --- changelog.d/+resolver-group.bugfix.md | 2 + .../test_resolver_upgrade.py | 43 +++++++++++++++++++ tools/refresh_uv_resolver.py | 15 +++++-- 3 files changed, 56 insertions(+), 4 deletions(-) create mode 100644 changelog.d/+resolver-group.bugfix.md diff --git a/changelog.d/+resolver-group.bugfix.md b/changelog.d/+resolver-group.bugfix.md new file mode 100644 index 0000000..980e554 --- /dev/null +++ b/changelog.d/+resolver-group.bugfix.md @@ -0,0 +1,2 @@ +Select the new uv resolver only from the `release-resolver` group, so a different uv in the test group no longer +stops the upgrade, and widen the pin from its current version. diff --git a/tests/refresh_release_lock/test_resolver_upgrade.py b/tests/refresh_release_lock/test_resolver_upgrade.py index 8900422..7cc3028 100644 --- a/tests/refresh_release_lock/test_resolver_upgrade.py +++ b/tests/refresh_release_lock/test_resolver_upgrade.py @@ -46,6 +46,49 @@ def test_resolver_upgrade_rejects_invalid_state( assert error in result.stderr +def test_resolver_widen_starts_at_the_current_pin(tmp_path: Path) -> None: + project = tmp_path / "pyproject.toml" + project.write_text('[dependency-groups]\nrelease-resolver = ["uv==0.13.6"]\n') + lock = tmp_path / "pylock.toml" + lock.write_text('dependency-groups = ["release-resolver"]\n' + _uv_package("0.13.6")) + + result = _resolver(tmp_path, "widen") + + assert result.returncode == 0, result.stderr + assert tomllib.loads(project.read_text())["dependency-groups"]["release-resolver"] == ["uv>=0.13.6"] + + +def test_resolver_pin_ignores_uv_from_other_groups(tmp_path: Path) -> None: + project = tmp_path / "pyproject.toml" + project.write_text('[dependency-groups]\nrelease-resolver = ["uv>=0.13.5"]\n') + lock = tmp_path / "pylock.toml" + lock.write_text( + 'dependency-groups = ["release-resolver", "test"]\n' + + _uv_package("0.13.6") + + "[[packages]]\nname = 'uv'\nversion = '0.14.0'\nmarker = '\"test\" in dependency_groups'\n" + ) + + result = _resolver(tmp_path, "pin") + + assert result.returncode == 0, result.stderr + assert tomllib.loads(project.read_text())["dependency-groups"]["release-resolver"] == ["uv==0.13.6"] + + +def _resolver(root: Path, mode: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [ + sys.executable, + str(Path(__file__).resolve().parents[2] / "tools/refresh_uv_resolver.py"), + mode, + str(root / "pyproject.toml"), + str(root / "pylock.toml"), + ], + text=True, + capture_output=True, + check=False, + ) + + @pytest.mark.parametrize( ("repository", "pin", "locked", "accepted"), [ diff --git a/tools/refresh_uv_resolver.py b/tools/refresh_uv_resolver.py index 26555db..05b00df 100644 --- a/tools/refresh_uv_resolver.py +++ b/tools/refresh_uv_resolver.py @@ -7,6 +7,7 @@ from pathlib import Path import tomlkit +from packaging.markers import InvalidMarker, Marker from tomlkit.exceptions import ParseError @@ -24,19 +25,25 @@ def main() -> None: if args.mode == "widen": if not str(requirements[0]).startswith("uv=="): raise ValueError("release-resolver must have an exact uv pin") - requirements[0] = "uv>=0.13.5" + requirements[0] = f"uv>={str(requirements[0]).removeprefix('uv==')}" else: - if requirements[0] != "uv>=0.13.5": + if not str(requirements[0]).startswith("uv>="): raise ValueError("release-resolver must have the temporary uv range") lock = tomllib.loads(args.lock.read_text()) if "release-resolver" not in lock["dependency-groups"]: raise ValueError("candidate lock omits release-resolver") - versions = {package["version"] for package in lock["packages"] if package["name"] == "uv"} + selected = {"dependency_groups": frozenset({"release-resolver"}), "extras": frozenset()} + versions = { + package["version"] + for package in lock["packages"] + if package["name"] == "uv" + and ("marker" not in package or Marker(package["marker"]).evaluate(selected, "lock_file")) + } if len(versions) != 1: raise ValueError("candidate lock must select one uv resolver version") requirements[0] = f"uv=={versions.pop()}" args.project.write_text(tomlkit.dumps(project)) - except (OSError, KeyError, TypeError, ValueError, ParseError, tomllib.TOMLDecodeError) as error: + except (InvalidMarker, OSError, KeyError, TypeError, ValueError, ParseError, tomllib.TOMLDecodeError) as error: parser.error(str(error)) From 11010413ca13d48cd7f9a6d6b3dbeff1b8cc8835 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sat, 24 Oct 2026 23:17:34 -0700 Subject: [PATCH 15/44] fix(news): check the lock when workflows change --- changelog.d/+news-workflow-lock.bugfix.md | 2 ++ changelog.d/+resolver-group.bugfix.md | 4 ++-- tests/validate_news/test_news.py | 5 +++++ validate-news/action.yml | 2 +- 4 files changed, 10 insertions(+), 3 deletions(-) create mode 100644 changelog.d/+news-workflow-lock.bugfix.md diff --git a/changelog.d/+news-workflow-lock.bugfix.md b/changelog.d/+news-workflow-lock.bugfix.md new file mode 100644 index 0000000..c22b635 --- /dev/null +++ b/changelog.d/+news-workflow-lock.bugfix.md @@ -0,0 +1,2 @@ +Check the release lock in pull requests that change a workflow, since re-pinning github-workflows can change the +pinned resolver and the rendered lock. diff --git a/changelog.d/+resolver-group.bugfix.md b/changelog.d/+resolver-group.bugfix.md index 980e554..42cd91a 100644 --- a/changelog.d/+resolver-group.bugfix.md +++ b/changelog.d/+resolver-group.bugfix.md @@ -1,2 +1,2 @@ -Select the new uv resolver only from the `release-resolver` group, so a different uv in the test group no longer -stops the upgrade, and widen the pin from its current version. +Select the new uv resolver only from the `release-resolver` group, so a different uv in the test group no longer stops +the upgrade, and widen the pin from its current version. diff --git a/tests/validate_news/test_news.py b/tests/validate_news/test_news.py index 1389158..613b4f4 100644 --- a/tests/validate_news/test_news.py +++ b/tests/validate_news/test_news.py @@ -146,6 +146,9 @@ def test_validate_news_forwards_secondary_lock_inputs() -> None: [ pytest.param("pylock.zipapp.toml", False, "pylock.zipapp.toml", "true", id="selected-secondary-lock"), pytest.param("", False, "pylock.zipapp.toml", "false", id="unselected-secondary-lock"), + pytest.param("", False, "pyproject.toml", "true", id="root-project"), + pytest.param("", False, ".github/workflows/release.yaml", "true", id="workflow-repin"), + pytest.param("", False, "README.md", "false", id="unrelated"), pytest.param("", False, "nab.toml", "true", id="nab-policy"), pytest.param("", False, "meta/pyproject.toml", "true", id="workspace-member"), pytest.param("", True, "uv.lock", "true", id="selected-native-uv-lock"), @@ -177,6 +180,8 @@ def test_validate_news_detects_release_input_change( (repository / changed_path).write_text( "[tool.nab]\nuploaded-prior-to = false\n" if changed_path == "nab.toml" + else (repository / changed_path).read_text() + "\n" + if changed_path == "pyproject.toml" else "lock-version = '1.0'\ncreated-by = 'test'\n" ) run(repository, "git", "add", changed_path) diff --git a/validate-news/action.yml b/validate-news/action.yml index 4624ea0..d75c6d6 100644 --- a/validate-news/action.yml +++ b/validate-news/action.yml @@ -49,7 +49,7 @@ runs: ADDITIONAL_LOCK: ${{ inputs.additional-lock }} KEEP_UV_LOCK: ${{ inputs.keep-uv-lock }} run: | - paths=(pyproject.toml ':(glob)**/pyproject.toml' pylock.toml nab.toml) + paths=(':(glob)**/pyproject.toml' pylock.toml nab.toml ':(glob).github/workflows/**') if [[ -n "$ADDITIONAL_LOCK" ]]; then paths+=("$ADDITIONAL_LOCK"); fi if [[ "$KEEP_UV_LOCK" == true ]]; then paths+=(uv.lock); fi changed=$(git diff --name-only "origin/$BASE_REF...HEAD" -- "${paths[@]}") From e920d8eb3eb7897f91a854c2544b2653559f6ab4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sat, 24 Oct 2026 23:17:34 -0700 Subject: [PATCH 16/44] test(news): cover primary lock edits and removal --- changelog.d/+news-workflow-lock.bugfix.md | 4 +-- tests/validate_news/test_news.py | 32 ++++++++++++++++++++++- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/changelog.d/+news-workflow-lock.bugfix.md b/changelog.d/+news-workflow-lock.bugfix.md index c22b635..ef0f3af 100644 --- a/changelog.d/+news-workflow-lock.bugfix.md +++ b/changelog.d/+news-workflow-lock.bugfix.md @@ -1,2 +1,2 @@ -Check the release lock in pull requests that change a workflow, since re-pinning github-workflows can change the -pinned resolver and the rendered lock. +Check the release lock in pull requests that change a workflow, since re-pinning github-workflows can change the pinned +resolver and the rendered lock. diff --git a/tests/validate_news/test_news.py b/tests/validate_news/test_news.py index 613b4f4..315635e 100644 --- a/tests/validate_news/test_news.py +++ b/tests/validate_news/test_news.py @@ -146,6 +146,7 @@ def test_validate_news_forwards_secondary_lock_inputs() -> None: [ pytest.param("pylock.zipapp.toml", False, "pylock.zipapp.toml", "true", id="selected-secondary-lock"), pytest.param("", False, "pylock.zipapp.toml", "false", id="unselected-secondary-lock"), + pytest.param("", False, "pylock.toml", "true", id="primary-lock"), pytest.param("", False, "pyproject.toml", "true", id="root-project"), pytest.param("", False, ".github/workflows/release.yaml", "true", id="workflow-repin"), pytest.param("", False, "README.md", "false", id="unrelated"), @@ -181,7 +182,7 @@ def test_validate_news_detects_release_input_change( "[tool.nab]\nuploaded-prior-to = false\n" if changed_path == "nab.toml" else (repository / changed_path).read_text() + "\n" - if changed_path == "pyproject.toml" + if changed_path in {"pylock.toml", "pyproject.toml"} else "lock-version = '1.0'\ncreated-by = 'test'\n" ) run(repository, "git", "add", changed_path) @@ -206,3 +207,32 @@ def test_validate_news_detects_release_input_change( assert result.returncode == 0, result.stderr assert f"changed={changed}\n" in output.read_text() + + +def test_validate_news_rejects_removed_release_lock(repository: Path, tmp_path: Path, action_scripts: Path) -> None: + (repository / "pylock.toml").write_text("lock-version = '1.0'\n") + run(repository, "git", "add", ".") + run(repository, "git", "commit", "-m", "Add release lock") + run(repository, "git", "push", "origin", "main") + run(repository, "git", "checkout", "-b", "remove-lock") + run(repository, "git", "rm", "-q", "pylock.toml") + run(repository, "git", "commit", "-m", "Remove release lock") + + result = subprocess.run( + ["bash", str(action_scripts / "validate-news-2.sh")], + cwd=repository, + env=os.environ + | { + "BASE_REF": "main", + "ADDITIONAL_LOCK": "", + "KEEP_UV_LOCK": "false", + "GITHUB_OUTPUT": str(tmp_path / "lock-change-output"), + "GITHUB_ACTION_PATH": str(Path(__file__).resolve().parents[2] / "validate-news"), + }, + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode != 0 + assert "the release lock was removed" in result.stderr From a135c0fdcd41118a5751ae2b1ea595da368268b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sat, 24 Oct 2026 23:19:14 -0700 Subject: [PATCH 17/44] fix(hooks): recheck after earlier heads mature --- changelog.d/+recheck-earlier-heads.bugfix.md | 2 + recheck-hook-update-cooldown/action.yml | 11 ++- .../test_recheck.py | 74 ++++++++++++++++++- 3 files changed, 82 insertions(+), 5 deletions(-) create mode 100644 changelog.d/+recheck-earlier-heads.bugfix.md diff --git a/changelog.d/+recheck-earlier-heads.bugfix.md b/changelog.d/+recheck-earlier-heads.bugfix.md new file mode 100644 index 0000000..e036a73 --- /dev/null +++ b/changelog.d/+recheck-earlier-heads.bugfix.md @@ -0,0 +1,2 @@ +Rerun a waiting hook cooldown check once any head of the pull request branch was observed seven days ago. The check +accepts revisions seen on earlier heads, so a later push no longer delays the rerun. diff --git a/recheck-hook-update-cooldown/action.yml b/recheck-hook-update-cooldown/action.yml index d33c8e8..5bd8870 100644 --- a/recheck-hook-update-cooldown/action.yml +++ b/recheck-hook-update-cooldown/action.yml @@ -41,13 +41,14 @@ runs: branch=$(jq -r .branch <<<"$pull") head_repository=$(jq -r .headRepository <<<"$pull") opened=$(jq -r '.opened | fromdateiso8601' <<<"$pull") + # The check also accepts revisions observed on earlier heads, so maturity counts every head of the branch. runs=$(gh api --paginate -X GET "repos/$GITHUB_REPOSITORY/actions/workflows/$WORKFLOW/runs" \ - -f event=pull_request -f head_sha="$head" -f per_page=100 | - jq -sc --arg repository "$head_repository" --arg branch "$branch" --arg head "$head" \ + -f event=pull_request -f branch="$branch" -f per_page=100 | + jq -sc --arg repository "$head_repository" --arg branch "$branch" \ --argjson number "$number" --argjson opened "$opened" ' [.[].workflow_runs[] | select( .event == "pull_request" and .head_repository.full_name == $repository and - .head_branch == $branch and .head_sha == $head and + .head_branch == $branch and (any(.pull_requests[]; .number == $number) or (.pull_requests == [] and (.created_at | fromdateiso8601) >= $opened)) )]') @@ -56,7 +57,9 @@ runs: <<<"$runs" >/dev/null; then continue fi - run=$(jq -c '[.[] | select(.status == "completed")] | sort_by(.created_at) | last // empty' <<<"$runs") + run=$(jq -c --arg head "$head" ' + [.[] | select(.head_sha == $head and .status == "completed")] | sort_by(.created_at) | last // empty + ' <<<"$runs") if [[ -z "$run" ]]; then continue fi diff --git a/tests/recheck_hook_update_cooldown/test_recheck.py b/tests/recheck_hook_update_cooldown/test_recheck.py index d0e8922..043604f 100644 --- a/tests/recheck_hook_update_cooldown/test_recheck.py +++ b/tests/recheck_hook_update_cooldown/test_recheck.py @@ -243,7 +243,7 @@ def test_recheck_continues_after_failed_rerun( server.expect_request( "/api/v3/repos/tox-dev/fixture/actions/workflows/check.yaml/runs", method="GET", - query_string=f"event=pull_request&head_sha={str(number) * 40}&per_page=100", + query_string=f"branch=update-{number}&event=pull_request&per_page=100", ).respond_with_json( { "workflow_runs": [ @@ -285,3 +285,75 @@ def test_recheck_continues_after_failed_rerun( "/api/v3/repos/tox-dev/fixture/actions/jobs/97/rerun", "/api/v3/repos/tox-dev/fixture/actions/jobs/98/rerun", ] + + +@pytest.mark.parametrize( + ("older_head_age", "reruns"), + [pytest.param(8, 1, id="older-head-matured"), pytest.param(3, 0, id="all-heads-recent")], +) +def test_recheck_counts_observations_on_earlier_heads( + github_api: tuple[HTTPServer, dict[str, str]], + action_scripts: Path, + tmp_path: Path, + older_head_age: int, + reruns: int, +) -> None: + server, environment = github_api + server.expect_request("/api/v3/repos/tox-dev/fixture/pulls", method="GET").respond_with_json( + [ + { + "number": 7, + "head": {"sha": "a" * 40, "ref": "update", "repo": {"full_name": "tox-dev/fixture"}}, + "base": {"repo": {"full_name": "tox-dev/fixture"}}, + "created_at": (datetime.now(UTC) - timedelta(days=20)).strftime("%Y-%m-%dT%H:%M:%SZ"), + } + ] + ) + server.expect_request("/api/v3/repos/tox-dev/fixture/pulls/7/files", method="GET").respond_with_json( + [{"filename": ".pre-commit-config.yaml"}] + ) + server.expect_request( + "/api/v3/repos/tox-dev/fixture/actions/workflows/check.yaml/runs", + method="GET", + query_string="branch=update&event=pull_request&per_page=100", + ).respond_with_json( + { + "workflow_runs": [ + { + "id": run_id, + "event": "pull_request", + "head_repository": {"full_name": "tox-dev/fixture"}, + "head_sha": head, + "head_branch": "update", + "pull_requests": [{"number": 7}], + "status": "completed", + "created_at": (datetime.now(UTC) - timedelta(days=age)).strftime("%Y-%m-%dT%H:%M:%SZ"), + } + for run_id, head, age in ((41, "b" * 40, older_head_age), (42, "a" * 40, 1)) + ] + } + ) + server.expect_request("/api/v3/repos/tox-dev/fixture/actions/runs/42/jobs", method="GET").respond_with_json( + {"jobs": [{"id": 99, "name": "hook-cooldown", "conclusion": "failure"}]} + ) + server.expect_request("/api/v3/repos/tox-dev/fixture/actions/jobs/99", method="GET").respond_with_json( + {"id": 99, "run_id": 42, "name": "hook-cooldown", "status": "completed", "conclusion": "failure"} + ) + server.expect_request("/api/v3/repos/tox-dev/fixture/actions/jobs/99/rerun", method="POST").respond_with_data( + status=201 + ) + + result = action( + tmp_path, + tmp_path, + action_scripts, + "recheck-hook-update-cooldown", + version="", + GH_TOKEN=environment["GH_ENTERPRISE_TOKEN"], + **environment, + ) + + assert result.returncode == 0, result.stderr + assert [request.path for request, _ in server.log].count( + "/api/v3/repos/tox-dev/fixture/actions/jobs/99/rerun" + ) == reruns From 5573523c1af649036a82a2e273b6a25e97236de9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sat, 24 Oct 2026 23:19:46 -0700 Subject: [PATCH 18/44] fix(hooks): explain an invalid cooldown minimum --- check-hook-update-cooldown/action.yml | 4 ++-- tests/check_hook_update_cooldown/test_cooldown.py | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/check-hook-update-cooldown/action.yml b/check-hook-update-cooldown/action.yml index d9df126..1601a71 100644 --- a/check-hook-update-cooldown/action.yml +++ b/check-hook-update-cooldown/action.yml @@ -23,10 +23,10 @@ runs: PR_NUMBER=$(jq -r .number "$GITHUB_EVENT_PATH") HEAD_SHA=$(jq -r .pull_request.head.sha "$GITHUB_EVENT_PATH") case "$MINIMUM_DAYS" in - '' | 0* | *[!0-9]*) exit 1 ;; + '' | 0* | *[!0-9]*) MINIMUM_DAYS=0 ;; esac if ((MINIMUM_DAYS < 7)); then - echo '::error::hook cooldown must be at least seven days' >&2 + echo '::error::minimum-days must be a whole number of at least seven' >&2 exit 1 fi gh api "repos/$REPOSITORY/pulls/$PR_NUMBER" >"$RUNNER_TEMP/hook-update-pr.json" diff --git a/tests/check_hook_update_cooldown/test_cooldown.py b/tests/check_hook_update_cooldown/test_cooldown.py index ccc223d..6f60589 100644 --- a/tests/check_hook_update_cooldown/test_cooldown.py +++ b/tests/check_hook_update_cooldown/test_cooldown.py @@ -560,6 +560,7 @@ def test_cooldown_rejects_invalid_minimum( server, environment = github_api result = run_cooldown(action_scripts, tmp_path, environment, minimum_days=minimum_days) assert result.returncode != 0 + assert "minimum-days must be a whole number of at least seven" in result.stderr assert not server.log From f211eb7351797acf95e6520c30059b5f0daf214a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sat, 24 Oct 2026 23:20:02 -0700 Subject: [PATCH 19/44] test(hooks): require the App on the updater branch --- .../test_cooldown.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/check_hook_update_cooldown/test_cooldown.py b/tests/check_hook_update_cooldown/test_cooldown.py index 6f60589..ea7df86 100644 --- a/tests/check_hook_update_cooldown/test_cooldown.py +++ b/tests/check_hook_update_cooldown/test_cooldown.py @@ -487,6 +487,26 @@ def test_cooldown_accepts_managed_pin_with_python_marker( assert (tmp_path / "hook-closure/.pre-commit-config.yaml").read_text() == source +@pytest.mark.parametrize( + ("author", "client_id"), + [ + pytest.param("gaborbernat", "Iv1.release", id="human-author"), + pytest.param("release-app[bot]", "", id="unconfigured-app"), + ], +) +def test_cooldown_requires_configured_app_on_updater_branch( + tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]], author: str, client_id: str +) -> None: + server, environment = github_api + expect_pull(server, author=author, branch="automation/update-release-tools") + + result = run_cooldown(action_scripts, tmp_path, environment | {"RELEASE_APP_CLIENT_ID": client_id}) + + assert result.returncode != 0 + assert "release tool branch requires the configured release App" in result.stderr + assert "/api/v3/apps/release-app" not in [request.path for request, _ in server.log] + + @pytest.mark.parametrize( ("client_id", "head_repository"), [ From 495e6f40a831fbe378e3516ddbabea77a7450406 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sat, 24 Oct 2026 23:21:43 -0700 Subject: [PATCH 20/44] test(hooks): give the frozen hook test a unique name --- .../{test_verify.py => test_frozen_hook_action.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/verify_frozen_hooks/{test_verify.py => test_frozen_hook_action.py} (100%) diff --git a/tests/verify_frozen_hooks/test_verify.py b/tests/verify_frozen_hooks/test_frozen_hook_action.py similarity index 100% rename from tests/verify_frozen_hooks/test_verify.py rename to tests/verify_frozen_hooks/test_frozen_hook_action.py From df1a6e59b94cb3300d196feea8e5a6ec088c455a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sat, 24 Oct 2026 23:23:41 -0700 Subject: [PATCH 21/44] feat(lock)!: drop the Nab primary lock path --- .github/workflows/update-tools.yaml | 2 - .github/workflows/upgrade-release-tools.yaml | 5 - .../+remove-nab-primary-lock.breaking.md | 3 + refresh-release-lock/action.yml | 53 +-- tests/refresh_release_lock/test_provider.py | 10 +- tests/upgrade_release_tools/test_upgrade.py | 73 ---- tests/validate_news/test_lock_groups.py | 327 ++---------------- tools/check_lock_groups.py | 4 +- tools/lock_groups.py | 69 +--- validate-news/action.yml | 3 - 10 files changed, 57 insertions(+), 492 deletions(-) create mode 100644 changelog.d/+remove-nab-primary-lock.breaking.md diff --git a/.github/workflows/update-tools.yaml b/.github/workflows/update-tools.yaml index b09934e..2897e72 100644 --- a/.github/workflows/update-tools.yaml +++ b/.github/workflows/update-tools.yaml @@ -13,8 +13,6 @@ jobs: RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} with: upgrade-resolver: true - lock-groups: >- - build lock-tools lock-reader release-notes release-resolver release-news release-prepare release-validate release-check release-attestations release-shell release-hooks release-hook-check commit-message: "🔧 build: refresh release tool lock" pull-request-title: "🔧 build: refresh release tool lock" pull-request-body: "Refresh the locked tools used by release preparation and CI." diff --git a/.github/workflows/upgrade-release-tools.yaml b/.github/workflows/upgrade-release-tools.yaml index 78aaaf2..0b39256 100644 --- a/.github/workflows/upgrade-release-tools.yaml +++ b/.github/workflows/upgrade-release-tools.yaml @@ -2,10 +2,6 @@ name: Upgrade release tools on: workflow_call: inputs: - lock-groups: - description: Legacy Nab dependency groups; ignored for full project locks - default: "" - type: string additional-lock: description: Optional secondary pylock basename for a separate build matrix type: string @@ -77,7 +73,6 @@ jobs: - id: lock uses: $/refresh-release-lock with: - lock-groups: ${{ inputs.lock-groups }} additional-lock: ${{ inputs.additional-lock }} additional-lock-groups: ${{ inputs.additional-lock-groups }} additional-lock-selection: ${{ inputs.additional-lock-selection }} diff --git a/changelog.d/+remove-nab-primary-lock.breaking.md b/changelog.d/+remove-nab-primary-lock.breaking.md new file mode 100644 index 0000000..1d5e987 --- /dev/null +++ b/changelog.d/+remove-nab-primary-lock.breaking.md @@ -0,0 +1,3 @@ +Remove the Nab primary lock path and the `lock-groups` input of `refresh-release-lock` and `upgrade-release-tools`. +`pylock.toml` must now come from `release-creator locks`; relock a Nab primary lock before re-pinning. Nab remains for +secondary `additional-lock` matrices. diff --git a/refresh-release-lock/action.yml b/refresh-release-lock/action.yml index f959a2b..d3ce163 100644 --- a/refresh-release-lock/action.yml +++ b/refresh-release-lock/action.yml @@ -1,9 +1,6 @@ name: Refresh a release-tool lock -description: Refresh or check a full project lock and optional Nab matrix lock +description: Refresh or check a release-creator project lock and optional Nab matrix lock inputs: - lock-groups: - description: Space-separated dependency groups for legacy Nab locks - default: "" additional-lock: description: Optional secondary pylock basename for a separate build matrix default: "" @@ -38,8 +35,7 @@ outputs: runs: using: composite steps: - - id: producer - name: Identify lock producer + - name: Check lock inputs shell: bash env: ADDITIONAL_LOCK: ${{ inputs.additional-lock }} @@ -57,30 +53,22 @@ runs: echo '::error::secondary matrix inputs require additional-lock' >&2 exit 1 fi - provider=$(python3 "$GITHUB_ACTION_PATH/../tools/lock_groups.py" pylock.toml --provider) - if [[ "$KEEP_UV_LOCK" == true && "$provider" != release-creator ]]; then - echo '::error::keep-uv-lock requires a release-creator lock' >&2 - exit 1 - fi + python3 "$GITHUB_ACTION_PATH/../tools/lock_groups.py" pylock.toml if [[ "$KEEP_UV_LOCK" == true ]] && ! git ls-files --error-unmatch uv.lock >/dev/null 2>&1; then echo '::error::keep-uv-lock requires a committed uv.lock' >&2 exit 1 fi - echo "name=$provider" >>"$GITHUB_OUTPUT" - echo "groups=$(python3 "$GITHUB_ACTION_PATH/../tools/lock_groups.py" pylock.toml)" >>"$GITHUB_OUTPUT" - uses: $/install-locked-tools with: groups: lock-tools lock-source: shared venv: ${{ runner.temp }}/lock-tools - uses: $/install-locked-tools - if: steps.producer.outputs.name == 'release-creator' with: groups: release-notes lock-source: shared venv: ${{ runner.temp }}/project-lock-tools - uses: $/install-locked-tools - if: steps.producer.outputs.name == 'release-creator' with: groups: release-resolver lock-source: shared @@ -104,42 +92,7 @@ runs: } uv run --no-project --python "$RUNNER_TEMP/lock-tools" python \ "$GITHUB_ACTION_PATH/../tools/refresh_uv_resolver.py" widen pyproject.toml pylock.toml - - name: Resolve selected Nab groups - if: steps.producer.outputs.name == 'nab' - shell: bash - env: - LOCK_GROUPS: ${{ inputs.lock-groups || steps.producer.outputs.groups }} - MODE: ${{ inputs.mode }} - run: | - set -euo pipefail - groups=() - while IFS= read -r line; do - read -ra selected <<<"$line" - groups+=("${selected[@]}") - done <<<"$LOCK_GROUPS" - if ((${#groups[@]} == 0)); then - echo '::error::at least one lock group is required' >&2 - exit 1 - fi - case "$MODE" in - check) - refresh=() - cp pylock.toml "$RUNNER_TEMP/pylock-before-check.toml" - ;; - refresh) refresh=(--upgrade) ;; - *) - echo "::error::invalid lock mode: $MODE" >&2 - exit 1 - ;; - esac - uv run --no-project --python "$RUNNER_TEMP/lock-tools" nab lock --no-cache --groups "${groups[@]}" \ - --output pylock.toml "${refresh[@]}" - if [[ "$MODE" == check ]]; then - python3 "$GITHUB_ACTION_PATH/../tools/check_nab_lock.py" \ - "$RUNNER_TEMP/pylock-before-check.toml" pylock.toml - fi - name: Resolve all project groups and extras - if: steps.producer.outputs.name == 'release-creator' shell: bash env: MODE: ${{ inputs.mode }} diff --git a/tests/refresh_release_lock/test_provider.py b/tests/refresh_release_lock/test_provider.py index 807d893..ce01b1c 100644 --- a/tests/refresh_release_lock/test_provider.py +++ b/tests/refresh_release_lock/test_provider.py @@ -36,23 +36,19 @@ def test_refresh_rejects_secondary_options_without_lock(tmp_path: Path, action_s assert "secondary matrix inputs require additional-lock" in result.stderr -def test_refresh_rejects_native_uv_lock_for_nab(tmp_path: Path, action_scripts: Path) -> None: +def test_refresh_rejects_nab_primary_lock(tmp_path: Path, action_scripts: Path) -> None: (tmp_path / "pylock.toml").write_text('[tool.nab]\ncommand-line = ["nab", "lock"]\n') - result = action(tmp_path, tmp_path, action_scripts, "refresh-release-lock", version="", KEEP_UV_LOCK="true") + result = action(tmp_path, tmp_path, action_scripts, "refresh-release-lock", version="", MODE="check") assert result.returncode != 0 - assert "keep-uv-lock requires a release-creator lock" in result.stderr + assert "pylock.toml must come from release-creator locks" in result.stderr @pytest.mark.parametrize("mode", ["check", "refresh"]) def test_refresh_uses_the_locked_resolver(tmp_path: Path, action_scripts: Path, mode: str) -> None: source = Path(__file__).resolve().parents[2] steps = yaml.safe_load((source / "refresh-release-lock/action.yml").read_text())["runs"]["steps"] - resolver = next(step for step in steps if step.get("with", {}).get("groups") == "release-resolver") - assert resolver["if"] == "steps.producer.outputs.name == 'release-creator'" - assert resolver["with"]["lock-source"] == "shared" - assert resolver["with"]["venv"] == "${{ runner.temp }}/release-resolver" packages = tomllib.loads((source / "pylock.toml").read_text())["packages"] pinned = next(package for package in packages if package["name"] == "uv") assert Version(pinned["version"]) >= Version("0.13.5") diff --git a/tests/upgrade_release_tools/test_upgrade.py b/tests/upgrade_release_tools/test_upgrade.py index 9069919..78eeabd 100644 --- a/tests/upgrade_release_tools/test_upgrade.py +++ b/tests/upgrade_release_tools/test_upgrade.py @@ -48,79 +48,6 @@ def lock_repository(tmp_path: Path) -> Path: return repository -def test_upgrade_locks_only_selected_groups(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"]')) - installed = action( - lock_repository, - tmp_path, - upgrade_scripts, - "install-locked-tools", - version="", - GROUPS="lock-tools", - LOCK_SOURCE="shared", - VENV=str(tmp_path / "lock-tools"), - ) - assert installed.returncode == 0, installed.stderr - resolved = execute( - lock_repository, - refresh_script(upgrade_scripts, "Resolve selected Nab groups"), - tmp_path, - LOCK_GROUPS="release-check", - MODE="refresh", - ) - assert resolved.returncode == 0, resolved.stderr - lock = tomllib.loads((lock_repository / "pylock.toml").read_text()) - assert lock["dependency-groups"] == ["project-runtime", "release-check"] - assert "test" not in {package["name"] for package in lock["packages"]} - - -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"]') - + '[tool.nab.packages.old]\nuploaded-prior-to = "2020-01-01T00:00:00Z"\n' - ) - installed = action( - lock_repository, - tmp_path, - upgrade_scripts, - "install-locked-tools", - version="", - GROUPS="lock-tools", - LOCK_SOURCE="shared", - VENV=str(tmp_path / "lock-tools"), - ) - assert installed.returncode == 0, installed.stderr - refreshed = execute( - lock_repository, - refresh_script(upgrade_scripts, "Resolve selected Nab groups"), - tmp_path, - LOCK_GROUPS="release-check", - MODE="refresh", - ) - assert refreshed.returncode == 0, refreshed.stderr - expected = (lock_repository / "pylock.toml").read_bytes() - 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, - tmp_path, - upgrade_scripts, - "refresh-release-lock", - version="", - LOCK_GROUPS="release-check", - MODE="check", - ) - 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 - - def test_upgrade_preserves_deliberate_nab_hold(lock_repository: Path, tmp_path: Path, upgrade_scripts: Path) -> None: project = lock_repository / "pyproject.toml" project.write_text( diff --git a/tests/validate_news/test_lock_groups.py b/tests/validate_news/test_lock_groups.py index de5308a..713eacd 100644 --- a/tests/validate_news/test_lock_groups.py +++ b/tests/validate_news/test_lock_groups.py @@ -7,202 +7,74 @@ import pytest +from tools.lock_groups import validate_workspace_sources -def test_lock_groups_reads_recorded_selection(tmp_path: Path) -> None: - (tmp_path / "pyproject.toml").write_text( - "[dependency-groups]\nrelease = []\nrelease-news = []\nrelease-check = []\n" +TOOLS = Path(__file__).resolve().parents[2] / "tools" + + +def _producer(lock: Path) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, str(TOOLS / "lock_groups.py"), str(lock)], capture_output=True, text=True, check=False ) + + +def test_lock_groups_accepts_release_creator_lock(tmp_path: Path) -> None: lock = tmp_path / "pylock.toml" - lock.write_text( - '[tool.nab]\ncommand-line = ["nab", "lock", "--groups", "release-news", "release-check", ' - '"--output", "pylock.toml"]\n' - ) + lock.write_text("[tool.release-creator]\ncreated-at = 2026-10-24T00:00:00Z\n") - result = subprocess.run( - ["python3", str(Path(__file__).resolve().parents[2] / "tools/lock_groups.py"), str(lock)], - capture_output=True, - text=True, - check=False, - ) + result = _producer(lock) - assert result.returncode == 0 - assert result.stdout == "release-news release-check\n" + assert result.returncode == 0, result.stderr @pytest.mark.parametrize( - ("command", "message"), + ("content", "message"), [ - pytest.param('["nab", "lock", "--output", "pylock.toml"]', "cannot read lock groups", id="missing-flag"), - pytest.param('"nab lock --groups release-news"', "must be a list of strings", id="string-command"), - pytest.param('["nab", 42]', "must be a list of strings", id="non-string-argument"), - pytest.param( - '["nab", "lock", "--groups", "--output", "pylock.toml"]', - "Nab lock records no selected groups", - id="empty-groups", - ), + pytest.param('[tool.nab]\ncommand-line = ["nab", "lock"]\n', "must come from release-creator locks", id="nab"), + pytest.param('lock-version = "1.0"\n', "no recognized producer", id="unknown"), ], ) -def test_lock_groups_rejects_missing_selection(tmp_path: Path, command: str, message: str) -> None: +def test_lock_groups_rejects_other_producer(tmp_path: Path, content: str, message: str) -> None: lock = tmp_path / "pylock.toml" - lock.write_text(f"[tool.nab]\ncommand-line = {command}\n") + lock.write_text(content) - result = subprocess.run( - ["python3", str(Path(__file__).resolve().parents[2] / "tools/lock_groups.py"), str(lock)], - capture_output=True, - text=True, - check=False, - ) + result = _producer(lock) assert result.returncode != 0 assert message in result.stderr def test_lock_groups_import_has_no_cli_side_effect(capsys: pytest.CaptureFixture[str]) -> None: - runpy.run_path(str(Path(__file__).resolve().parents[2] / "tools/lock_groups.py"), run_name="lock_groups") + runpy.run_path(str(TOOLS / "lock_groups.py"), run_name="lock_groups") assert capsys.readouterr() == ("", "") -def test_lock_groups_reads_full_project_selection(tmp_path: Path) -> None: - (tmp_path / "pyproject.toml").write_text( - "[project.optional-dependencies]\nweb = []\n[dependency-groups]\nrelease-news = []\ntest = []\n" - ) - lock = tmp_path / "pylock.toml" - lock.write_text( - 'dependency-groups = ["project-runtime", "release-news", "test"]\nextras = ["web"]\ndefault-groups = []\n' - "[tool.nab]\ncreated-at = 2026-10-24T00:00:00Z\n" - "[tool.release-creator]\ncreated-at = 2026-10-24T00:00:00Z\n" - ) - - result = subprocess.run( - ["python3", str(Path(__file__).resolve().parents[2] / "tools/lock_groups.py"), str(lock)], - capture_output=True, - text=True, - check=False, - ) - - assert (result.returncode, result.stdout) == (0, "release-news\n") - - -def test_lock_groups_includes_workspace_members_and_exclusions(tmp_path: Path) -> None: - (tmp_path / "pyproject.toml").write_text( - '[tool.uv.workspace]\nmembers = ["packages/*"]\nexclude = ["packages/unused"]\n' - "[dependency-groups]\nrelease-news = []\n" - ) - for name, groups, extras in ( - ("included", "test = []\nrelease-package = []\n", "web = []\n"), - ("unused", "stale = []\n", "stale = []\n"), - ): - member = tmp_path / "packages" / name - member.mkdir(parents=True) - (member / "pyproject.toml").write_text( - f"[dependency-groups]\n{groups}[project.optional-dependencies]\n{extras}" - ) - lock = tmp_path / "pylock.toml" - lock.write_text( - 'dependency-groups = ["project-runtime", "release-news", "release-package", "test"]\n' - 'extras = ["web"]\ndefault-groups = []\n[tool.release-creator]\n' - ) - - result = subprocess.run( - ["python3", str(Path(__file__).resolve().parents[2] / "tools/lock_groups.py"), str(lock)], - capture_output=True, - text=True, - check=False, - ) - - assert (result.returncode, result.stdout) == (0, "release-news release-package\n") - - -def test_lock_groups_accepts_declared_workspace_source(tmp_path: Path) -> None: +def test_workspace_sources_accept_declared_member(tmp_path: Path) -> None: (tmp_path / "pyproject.toml").write_text( - '[project]\nname = "bare"\n[tool.uv.workspace]\nmembers = ["packages/*"]\n' - "[dependency-groups]\nrelease-news = []\n" + '[project]\nname = "bare"\n[tool.uv.workspace]\nmembers = ["packages/*"]\nexclude = ["packages/unused"]\n' ) - for name in ("meta", "other"): + for name in ("meta", "other", "unused"): member = tmp_path / "packages" / name member.mkdir(parents=True) (member / "pyproject.toml").write_text( - f'[project]\nname = "{name}"\n[dependency-groups]\nrelease-{name} = []\n' + f'[project]\nname = "{name}"\n' + ("[tool.uv.sources]\nbare = { workspace = true }\n" if name == "meta" else "") + + ("[tool.uv.sources]\nmissing = { workspace = true }\n" if name == "unused" else "") ) (tmp_path / "packages" / "notes.txt").write_text("not a project") - lock = tmp_path / "pylock.toml" - lock.write_text( - 'dependency-groups = ["project-runtime", "release-news", "release-meta", "release-other"]\n' - "extras = []\ndefault-groups = []\n[tool.release-creator]\n" - ) - - result = subprocess.run( - ["python3", str(Path(__file__).resolve().parents[2] / "tools/lock_groups.py"), str(lock)], - capture_output=True, - text=True, - check=False, - ) - - assert (result.returncode, result.stdout) == (0, "release-news release-meta release-other\n") - - -@pytest.mark.parametrize( - ("groups", "extras", "message"), - [ - pytest.param('"release-news"', "[]", "invalid dependency groups", id="groups-not-list"), - pytest.param('["release-news", 1]', "[]", "invalid dependency groups", id="non-string-group"), - pytest.param('["project-runtime", "release-news"]', '"web"', "invalid extras", id="extras-not-list"), - pytest.param('["project-runtime", "release-news"]', "[1]", "invalid extras", id="non-string-extra"), - pytest.param( - '["project-runtime", "release-news"]', - '["web", "unknown"]', - "invalid or undeclared extras", - id="undeclared-extra", - ), - pytest.param( - '["project-runtime", "release-news"]', - '["web", "web"]', - "invalid or undeclared extras", - id="duplicate-extra", - ), - ], -) -def test_lock_groups_rejects_malformed_project_selections( - tmp_path: Path, groups: str, extras: str, message: str -) -> None: - (tmp_path / "pyproject.toml").write_text( - "[dependency-groups]\nrelease-news = []\n[project.optional-dependencies]\nweb = []\n" - ) - lock = tmp_path / "pylock.toml" - lock.write_text(f"dependency-groups = {groups}\nextras = {extras}\ndefault-groups = []\n[tool.release-creator]\n") - result = subprocess.run( - ["python3", str(Path(__file__).resolve().parents[2] / "tools/lock_groups.py"), str(lock)], - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode != 0 - assert message in result.stderr + validate_workspace_sources(tmp_path / "pyproject.toml") -def test_lock_groups_rejects_invalid_workspace_patterns(tmp_path: Path) -> None: +def test_workspace_sources_reject_invalid_patterns(tmp_path: Path) -> None: (tmp_path / "pyproject.toml").write_text("[tool.uv.workspace]\nmembers = [1]\n") - lock = tmp_path / "pylock.toml" - lock.write_text( - 'dependency-groups = ["project-runtime"]\nextras = []\ndefault-groups = []\n[tool.release-creator]\n' - ) - - result = subprocess.run( - ["python3", str(Path(__file__).resolve().parents[2] / "tools/lock_groups.py"), str(lock)], - capture_output=True, - text=True, - check=False, - ) - assert result.returncode != 0 - assert "workspace patterns must be strings" in result.stderr + with pytest.raises(ValueError, match="workspace patterns must be strings"): + validate_workspace_sources(tmp_path / "pyproject.toml") -def test_lock_groups_rejects_workspace_member_outside_project(tmp_path: Path) -> None: +def test_workspace_sources_reject_member_outside_project(tmp_path: Path) -> None: project = tmp_path / "project" project.mkdir() (project / "pyproject.toml").write_text('[tool.uv.workspace]\nmembers = ["external"]\n') @@ -210,21 +82,9 @@ def test_lock_groups_rejects_workspace_member_outside_project(tmp_path: Path) -> outside.mkdir() (outside / "pyproject.toml").write_text("[dependency-groups]\nrelease-hidden = []\n") (project / "external").symlink_to(outside, target_is_directory=True) - lock = project / "pylock.toml" - lock.write_text( - 'dependency-groups = ["project-runtime", "release-hidden"]\n' - "extras = []\ndefault-groups = []\n[tool.release-creator]\n" - ) - result = subprocess.run( - ["python3", str(Path(__file__).resolve().parents[2] / "tools/lock_groups.py"), str(lock)], - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode != 0 - assert "workspace members must be inside the project" in result.stderr + with pytest.raises(ValueError, match="workspace members must be inside the project"): + validate_workspace_sources(project / "pyproject.toml") @pytest.mark.parametrize( @@ -235,7 +95,7 @@ def test_lock_groups_rejects_workspace_member_outside_project(tmp_path: Path) -> pytest.param('bare = { workspace = true, git = "https://example.org/bare" }', id="mixed-source"), ], ) -def test_lock_groups_rejects_unapproved_workspace_sources_in_pr_and_release(tmp_path: Path, source: str) -> None: +def test_lock_check_rejects_unapproved_workspace_sources(tmp_path: Path, source: str) -> None: (tmp_path / "pyproject.toml").write_text( '[project]\nname = "bare"\nrequires-python = ">=3.15"\n' '[tool.uv.workspace]\nmembers = ["meta"]\n[dependency-groups]\nrelease-news = []\n' @@ -251,135 +111,20 @@ def test_lock_groups_rejects_unapproved_workspace_sources_in_pr_and_release(tmp_ 'dependency-groups = ["project-runtime", "release-news"]\n' "default-groups = []\npackages = []\n[tool.release-creator]\ncreated-at = 2026-10-20T00:00:00Z\n" ) - tools = Path(__file__).resolve().parents[2] / "tools" - for command in ( - ["python3", str(tools / "lock_groups.py"), str(lock)], + result = subprocess.run( [ sys.executable, - str(tools / "check_lock_groups.py"), + str(TOOLS / "check_lock_groups.py"), str(lock), "release-news", "--project", str(tmp_path / "pyproject.toml"), ], - ): - result = subprocess.run(command, capture_output=True, text=True, check=False) - assert result.returncode != 0 - assert "uv source must name a workspace project" in result.stderr - - -@pytest.mark.parametrize( - ("groups", "extras", "message"), - [ - pytest.param('["project-runtime", "release-news"]', '["web"]', "omits dependency groups", id="group"), - pytest.param('["project-runtime", "release-news", "test"]', "[]", "omits extras", id="extra"), - ], -) -def test_lock_groups_rejects_incomplete_project_lock(tmp_path: Path, groups: str, extras: str, message: str) -> None: - (tmp_path / "pyproject.toml").write_text( - "[project.optional-dependencies]\nweb = []\n[dependency-groups]\nrelease-news = []\ntest = []\n" - ) - lock = tmp_path / "pylock.toml" - lock.write_text(f"dependency-groups = {groups}\nextras = {extras}\ndefault-groups = []\n[tool.release-creator]\n") - - result = subprocess.run( - ["python3", str(Path(__file__).resolve().parents[2] / "tools/lock_groups.py"), str(lock)], - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode != 0 - assert message in result.stderr - - -@pytest.mark.parametrize( - ("extra_group", "message"), - [ - pytest.param("release-x\\nchanged=false", "invalid dependency groups", id="output-injection"), - pytest.param("release-other", "invalid or undeclared", id="undeclared"), - pytest.param("release-news", "invalid or undeclared", id="duplicate"), - ], -) -def test_lock_groups_rejects_extra_full_lock_group(tmp_path: Path, extra_group: str, message: str) -> None: - (tmp_path / "pyproject.toml").write_text("[dependency-groups]\nrelease-news = []\n") - lock = tmp_path / "pylock.toml" - lock.write_text( - f'dependency-groups = ["project-runtime", "release-news", "{extra_group}"]\n' - "extras = []\ndefault-groups = []\n[tool.release-creator]\n" - ) - - result = subprocess.run( - ["python3", str(Path(__file__).resolve().parents[2] / "tools/lock_groups.py"), str(lock)], - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode != 0 - assert message in result.stderr - - -@pytest.mark.parametrize( - "default_groups", - [pytest.param("[]", id="empty"), pytest.param('["test"]', id="test"), pytest.param(None, id="missing")], -) -def test_lock_groups_requires_empty_defaults(tmp_path: Path, default_groups: str | None) -> None: - (tmp_path / "pyproject.toml").write_text("[dependency-groups]\nrelease-news = []\ntest = []\n") - lock = tmp_path / "pylock.toml" - defaults = f"default-groups = {default_groups}\n" if default_groups is not None else "" - lock.write_text( - 'dependency-groups = ["project-runtime", "release-news", "test"]\n' - f"{defaults}[tool.release-creator]\ncreated-at = 2026-10-24T00:00:00Z\n" - ) - - result = subprocess.run( - ["python3", str(Path(__file__).resolve().parents[2] / "tools/lock_groups.py"), str(lock)], - capture_output=True, - text=True, - check=False, - ) - - assert (result.returncode == 0) is (default_groups == "[]") - - -def test_lock_groups_accepts_project_without_dependency_groups(tmp_path: Path) -> None: - (tmp_path / "pyproject.toml").write_text('[project]\nname = "example"\nversion = "1"\n') - lock = tmp_path / "pylock.toml" - lock.write_text( - 'dependency-groups = ["project-runtime"]\nextras = []\ndefault-groups = []\n[tool.release-creator]\n' - ) - - result = subprocess.run( - ["python3", str(Path(__file__).resolve().parents[2] / "tools/lock_groups.py"), str(lock)], - capture_output=True, - text=True, - check=False, - ) - - assert (result.returncode, result.stdout) == (0, "\n") - - -@pytest.mark.parametrize( - ("groups", "message"), - [ - pytest.param('"release-news"', "omits release groups", id="omitted-release-group"), - pytest.param('"release-news", "release-check", "unknown"', "invalid or undeclared", id="undeclared-group"), - pytest.param('"release-news\\nmode=refresh", "release-check"', "invalid or undeclared", id="output-injection"), - ], -) -def test_lock_groups_rejects_untrusted_selection(tmp_path: Path, groups: str, message: str) -> None: - (tmp_path / "pyproject.toml").write_text("[dependency-groups]\nrelease-news = []\nrelease-check = []\n") - lock = tmp_path / "pylock.toml" - lock.write_text(f'[tool.nab]\ncommand-line = ["nab", "lock", "--groups", {groups}]\n') - - result = subprocess.run( - ["python3", str(Path(__file__).resolve().parents[2] / "tools/lock_groups.py"), str(lock)], capture_output=True, text=True, check=False, ) assert result.returncode != 0 - assert message in result.stderr + assert "uv source must name a workspace project" in result.stderr diff --git a/tools/check_lock_groups.py b/tools/check_lock_groups.py index 5c4ee07..d1940a3 100644 --- a/tools/check_lock_groups.py +++ b/tools/check_lock_groups.py @@ -19,7 +19,7 @@ from urllib.request import Request, urlopen import certifi -from lock_groups import lock_provider, project_selections +from lock_groups import lock_provider, validate_workspace_sources from nab_cutoffs import reviewed_cutoff, validate_indexes from packaging._parser import Variable from packaging.markers import Marker @@ -56,7 +56,7 @@ def main() -> None: if "nab" in project.get("tool", {}): validate_indexes(project["tool"]["nab"]) if lock_provider(lock.tool) == "release-creator": - project_selections(args.project) + validate_workspace_sources(args.project) if (missing := groups - set(lock.dependency_groups or ())) and not ( args.build_constraints and missing == {"build"} ): diff --git a/tools/lock_groups.py b/tools/lock_groups.py index bdec063..378fdf7 100644 --- a/tools/lock_groups.py +++ b/tools/lock_groups.py @@ -1,4 +1,4 @@ -"""Read the lock producer and its selected release groups.""" +"""Require a project lock produced by release-creator and check its workspace sources.""" from __future__ import annotations @@ -11,62 +11,17 @@ def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("lock", type=Path) - parser.add_argument("--provider", action="store_true") args = parser.parse_args() try: with args.lock.open("rb") as stream: - lock = tomllib.load(stream) - provider = lock_provider(lock.get("tool")) - if args.provider: - print(provider) - return - if provider == "release-creator": - declared, declared_extras = project_selections(args.lock.with_name("pyproject.toml")) - if lock.get("default-groups") != []: - raise ValueError("project lock must not install default groups") - selected = lock["dependency-groups"] - if not isinstance(selected, list) or not all(isinstance(group, str) for group in selected): - raise ValueError("project lock has invalid dependency groups") - if not all(group.isascii() and group.replace("-", "").isalnum() for group in selected): - raise ValueError("project lock has invalid dependency groups") - required = declared | {"project-runtime"} - if missing := required - set(selected): - raise ValueError(f"project lock omits dependency groups: {', '.join(sorted(missing))}") - if len(selected) != len(set(selected)) or set(selected) - required - {"build"}: - raise ValueError("project lock has invalid or undeclared dependency groups") - extras = lock.get("extras", []) - if not isinstance(extras, list) or not all(isinstance(extra, str) for extra in extras): - raise ValueError("project lock has invalid extras") - if missing_extras := declared_extras - set(extras): - raise ValueError(f"project lock omits extras: {', '.join(sorted(missing_extras))}") - if len(extras) != len(set(extras)) or set(extras) != declared_extras: - raise ValueError("project lock has invalid or undeclared extras") - print(" ".join(group for group in selected if group.startswith("release-"))) - return - command = lock["tool"]["nab"]["command-line"] - if not isinstance(command, list) or not all(isinstance(value, str) for value in command): - raise ValueError("Nab command-line must be a list of strings") - start = command.index("--groups") + 1 - groups = command[ - start : next( - (index for index in range(start, len(command)) if command[index].startswith("--")), len(command) - ) - ] - if not groups: - parser.error("Nab lock records no selected groups") - with args.lock.with_name("pyproject.toml").open("rb") as stream: - declared = tomllib.load(stream).get("dependency-groups", {}) - if not all(group.isascii() and group.replace("-", "").isalnum() and group in declared for group in groups): - raise ValueError("Nab lock selects an invalid or undeclared group") - required = {name for name in declared if name.startswith("release-")} - if missing := required - set(groups): - raise ValueError(f"Nab lock omits release groups: {', '.join(sorted(missing))}") - print(" ".join(groups)) - except (KeyError, OSError, TypeError, ValueError) as error: - parser.error(f"cannot read lock groups: {error}") - - -def project_selections(project: Path) -> tuple[set[str], set[str]]: + provider = lock_provider(tomllib.load(stream).get("tool")) + except (OSError, TypeError, ValueError) as error: + parser.error(f"cannot read the lock producer: {error}") + if provider != "release-creator": + parser.error(f"{args.lock} must come from release-creator locks; relock it") + + +def validate_workspace_sources(project: Path) -> None: with project.open("rb") as stream: root = tomllib.load(stream) workspace = root.get("tool", {}).get("uv", {}).get("workspace", {}) @@ -97,10 +52,6 @@ def matched(patterns: list[str]) -> set[Path]: for name, source in item.get("tool", {}).get("uv", {}).get("sources", {}).items(): if source != {"workspace": True} or _normalized_name(name) not in names: raise ValueError(f"uv source must name a workspace project: {name}") - return ( - {group for item in projects for group in item.get("dependency-groups", {})}, - {extra for item in projects for extra in item.get("project", {}).get("optional-dependencies", {})}, - ) def _normalized_name(name: str) -> str: @@ -122,5 +73,5 @@ def lock_provider(tool: Mapping[str, object] | None) -> str: __all__ = [ "lock_provider", "main", - "project_selections", + "validate_workspace_sources", ] diff --git a/validate-news/action.yml b/validate-news/action.yml index d75c6d6..185e240 100644 --- a/validate-news/action.yml +++ b/validate-news/action.yml @@ -65,14 +65,11 @@ runs: echo 'changed=false' >>"$GITHUB_OUTPUT" exit 0 fi - groups=$(python3 "$GITHUB_ACTION_PATH/../tools/lock_groups.py" pylock.toml) echo 'changed=true' >>"$GITHUB_OUTPUT" - echo "groups=$groups" >>"$GITHUB_OUTPUT" - name: Check the release lock against the project if: steps.lock-change.outputs.changed == 'true' uses: $/refresh-release-lock with: - lock-groups: ${{ steps.lock-change.outputs.groups }} additional-lock: ${{ inputs.additional-lock }} additional-lock-groups: ${{ inputs.additional-lock-groups }} additional-lock-selection: ${{ inputs.additional-lock-selection }} From e58e7d9079a8e915c4f18bbc4badfd71a0d873c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sat, 24 Oct 2026 23:23:41 -0700 Subject: [PATCH 22/44] perf(lock): install Nab only when a step needs it --- changelog.d/+lock-tools-on-demand.performance.md | 2 ++ refresh-release-lock/action.yml | 1 + 2 files changed, 3 insertions(+) create mode 100644 changelog.d/+lock-tools-on-demand.performance.md diff --git a/changelog.d/+lock-tools-on-demand.performance.md b/changelog.d/+lock-tools-on-demand.performance.md new file mode 100644 index 0000000..1910097 --- /dev/null +++ b/changelog.d/+lock-tools-on-demand.performance.md @@ -0,0 +1,2 @@ +Install the Nab lock tools in `refresh-release-lock` only for a refresh or a secondary lock. Lock checks in pull +requests, release preparation, and tagged releases skip that install. diff --git a/refresh-release-lock/action.yml b/refresh-release-lock/action.yml index d3ce163..38cfb28 100644 --- a/refresh-release-lock/action.yml +++ b/refresh-release-lock/action.yml @@ -59,6 +59,7 @@ runs: exit 1 fi - uses: $/install-locked-tools + if: inputs.mode == 'refresh' || inputs.additional-lock != '' with: groups: lock-tools lock-source: shared From 8d32650c6f74d27874c4382f1d1a2142d5604006 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sat, 24 Oct 2026 23:24:56 -0700 Subject: [PATCH 23/44] refactor(lock)!: detect the resolver upgrade repo --- .github/workflows/update-tools.yaml | 1 - .github/workflows/upgrade-release-tools.yaml | 5 ----- .../+remove-upgrade-resolver.breaking.md | 2 ++ refresh-release-lock/action.yml | 21 ++++++------------- .../test_resolver_upgrade.py | 14 ------------- 5 files changed, 8 insertions(+), 35 deletions(-) create mode 100644 changelog.d/+remove-upgrade-resolver.breaking.md diff --git a/.github/workflows/update-tools.yaml b/.github/workflows/update-tools.yaml index 2897e72..313dce5 100644 --- a/.github/workflows/update-tools.yaml +++ b/.github/workflows/update-tools.yaml @@ -12,7 +12,6 @@ jobs: secrets: RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} with: - upgrade-resolver: true commit-message: "🔧 build: refresh release tool lock" pull-request-title: "🔧 build: refresh release tool lock" pull-request-body: "Refresh the locked tools used by release preparation and CI." diff --git a/.github/workflows/upgrade-release-tools.yaml b/.github/workflows/upgrade-release-tools.yaml index 0b39256..81d90c7 100644 --- a/.github/workflows/upgrade-release-tools.yaml +++ b/.github/workflows/upgrade-release-tools.yaml @@ -26,10 +26,6 @@ on: description: Space-separated implementations for the secondary lock type: string default: "" - upgrade-resolver: - description: Upgrade this repository's exact shared uv resolver pin with its lock - type: boolean - default: false keep-uv-lock: description: Refresh and check uv.lock alongside pylock.toml type: boolean @@ -79,7 +75,6 @@ jobs: additional-matrix-python: ${{ inputs.additional-matrix-python }} additional-matrix-platforms: ${{ inputs.additional-matrix-platforms }} additional-matrix-implementations: ${{ inputs.additional-matrix-implementations }} - upgrade-resolver: ${{ inputs.upgrade-resolver }} keep-uv-lock: ${{ inputs.keep-uv-lock }} - id: upload uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 diff --git a/changelog.d/+remove-upgrade-resolver.breaking.md b/changelog.d/+remove-upgrade-resolver.breaking.md new file mode 100644 index 0000000..55c7c63 --- /dev/null +++ b/changelog.d/+remove-upgrade-resolver.breaking.md @@ -0,0 +1,2 @@ +Remove the `upgrade-resolver` input of `refresh-release-lock` and `upgrade-release-tools`. The shared uv resolver pin +is upgraded only in github-workflows, which the Action now detects from the repository name. diff --git a/refresh-release-lock/action.yml b/refresh-release-lock/action.yml index 38cfb28..be30d3c 100644 --- a/refresh-release-lock/action.yml +++ b/refresh-release-lock/action.yml @@ -25,9 +25,6 @@ inputs: keep-uv-lock: description: Refresh and check uv.lock alongside pylock.toml for projects that use both default: "false" - upgrade-resolver: - description: Upgrade the shared uv pin before refreshing this repository's lock - default: "false" outputs: changed: description: Whether the lock or expired package cutoffs differ from the checkout @@ -83,16 +80,10 @@ runs: run: >- uv run --no-project --python "$RUNNER_TEMP/lock-tools" python "$GITHUB_ACTION_PATH/../tools/expire_cutoffs.py" pyproject.toml - name: Widen the shared uv pin - if: inputs.mode == 'refresh' && inputs.upgrade-resolver == 'true' + if: inputs.mode == 'refresh' && github.repository == 'tox-dev/github-workflows' shell: bash - run: | - set -euo pipefail - [[ "$GITHUB_REPOSITORY" == tox-dev/github-workflows ]] || { - echo '::error::only github-workflows can upgrade the shared resolver' >&2 - exit 1 - } - uv run --no-project --python "$RUNNER_TEMP/lock-tools" python \ - "$GITHUB_ACTION_PATH/../tools/refresh_uv_resolver.py" widen pyproject.toml pylock.toml + run: >- + uv run --no-project --python "$RUNNER_TEMP/lock-tools" python "$GITHUB_ACTION_PATH/../tools/refresh_uv_resolver.py" widen pyproject.toml pylock.toml - name: Resolve all project groups and extras shell: bash env: @@ -112,17 +103,17 @@ runs: ;; esac - name: Pin the updated uv resolver - if: inputs.mode == 'refresh' && inputs.upgrade-resolver == 'true' + if: inputs.mode == 'refresh' && github.repository == 'tox-dev/github-workflows' shell: bash run: >- uv run --no-project --python "$RUNNER_TEMP/lock-tools" python "$GITHUB_ACTION_PATH/../tools/refresh_uv_resolver.py" pin pyproject.toml pylock.toml - uses: $/install-locked-tools - if: inputs.mode == 'refresh' && inputs.upgrade-resolver == 'true' + if: inputs.mode == 'refresh' && github.repository == 'tox-dev/github-workflows' with: groups: release-resolver venv: ${{ runner.temp }}/updated-release-resolver - name: Resolve with the updated uv resolver - if: inputs.mode == 'refresh' && inputs.upgrade-resolver == 'true' + if: inputs.mode == 'refresh' && github.repository == 'tox-dev/github-workflows' shell: bash env: KEEP_UV_LOCK: ${{ inputs.keep-uv-lock }} diff --git a/tests/refresh_release_lock/test_resolver_upgrade.py b/tests/refresh_release_lock/test_resolver_upgrade.py index 7cc3028..3c8dfef 100644 --- a/tests/refresh_release_lock/test_resolver_upgrade.py +++ b/tests/refresh_release_lock/test_resolver_upgrade.py @@ -131,20 +131,6 @@ def test_resolver_upgrade_import_has_no_side_effect() -> None: assert runpy.run_path(str(source), run_name="imported")["__all__"] == ["main"] -def test_weekly_resolver_upgrade_is_opt_in() -> None: - source = Path(__file__).resolve().parents[2] - caller = yaml.safe_load((source / ".github/workflows/update-tools.yaml").read_text()) - reusable = yaml.safe_load((source / ".github/workflows/upgrade-release-tools.yaml").read_text()) - action = yaml.safe_load((source / "refresh-release-lock/action.yml").read_text()) - - assert caller["jobs"]["update"]["with"]["upgrade-resolver"] is True - refresh = next( - step for step in reusable["jobs"]["generate-lock"]["steps"] if step.get("uses") == "$/refresh-release-lock" - ) - assert refresh["with"]["upgrade-resolver"] == "${{ inputs.upgrade-resolver }}" - assert action["inputs"]["upgrade-resolver"]["default"] == "false" - - def test_resolver_upgrade_uses_candidate_to_regenerate_lock(tmp_path: Path, action_scripts: Path) -> None: source = Path(__file__).resolve().parents[2] steps = yaml.safe_load((source / "refresh-release-lock/action.yml").read_text())["runs"]["steps"] From efbdbe52f7500eff87f2cb904cd3f49eafb3bd0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sat, 24 Oct 2026 23:25:32 -0700 Subject: [PATCH 24/44] refactor(notes)!: write notes to a fixed path --- .github/workflows/release.yaml | 1 - README.md | 12 +++++------ changelog.d/+fixed-notes-path.breaking.md | 2 ++ .../+remove-upgrade-resolver.breaking.md | 4 ++-- render-release-notes/action.yml | 8 ++----- tests/render_release_notes/test_notes.py | 21 ++++++++----------- 6 files changed, 20 insertions(+), 28 deletions(-) create mode 100644 changelog.d/+fixed-notes-path.breaking.md diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 5dbdfb1..f33b412 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -139,7 +139,6 @@ jobs: previous: ${{ needs.source.outputs.previous }} changelog: CHANGELOG.md generated-notes: ${{ runner.temp }}/generated-notes.md - output: ${{ runner.temp }}/release-notes.md - id: artifact uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: diff --git a/README.md b/README.md index 16f0693..6f6245b 100644 --- a/README.md +++ b/README.md @@ -532,7 +532,6 @@ creates the Release; this Action only writes a local Markdown file. changelog: CHANGELOG.md artifact-url: https://pypi.org/project/example/${{ github.ref_name }}/ generated-notes: ${{ runner.temp }}/generated-notes.md - output: ${{ runner.temp }}/release-notes.md ``` - `tag` **(required)**: Release version, such as `1.2.3`. The checkout must contain its changelog section. @@ -544,16 +543,15 @@ creates the Release; this Action only writes a local Markdown file. - `generated-notes`: Path to GitHub's generated-notes body. The tag publisher fetches it after PyPI publication. It preserves New Contributors and fills any missing `by @handle` credit. Omit it when the release has no GitHub-generated contributor metadata. -- `output` **(required)**: Writable path for the GitHub Release body. Pass it to `gh release create --notes-file` only - after the ecosystem publisher succeeds. The Action reads `GITHUB_REPOSITORY` for PR and comparison links. A repository with separate release namespaces, such as toml-fmt, calls `release-creator notes --package` directly for its package changelog. -**Output:** No named output. **Working-tree effect:** The Action writes only `output`. It rejects an invalid tag, a -missing heading, an empty body, or a missing comparison link when an earlier tag exists. It neither fetches generated -notes nor writes a GitHub Release, so a PR job needs only read permissions. Fetching generated notes uses GitHub's -release endpoint and needs Contents write in the tag publisher. +**Output:** No named output. **Working-tree effect:** The Action writes only `$RUNNER_TEMP/release-notes.md`. Pass it to +`gh release create --notes-file` only after the ecosystem publisher succeeds. It rejects an invalid tag, a missing +heading, an empty body, or a missing comparison link when an earlier tag exists. It neither fetches generated notes nor +writes a GitHub Release, so a PR job needs only read permissions. Fetching generated notes uses GitHub's release +endpoint and needs Contents write in the tag publisher. ## Select the previous release tag diff --git a/changelog.d/+fixed-notes-path.breaking.md b/changelog.d/+fixed-notes-path.breaking.md new file mode 100644 index 0000000..417a0c2 --- /dev/null +++ b/changelog.d/+fixed-notes-path.breaking.md @@ -0,0 +1,2 @@ +Remove the `render-release-notes` `output` input. Every caller passed `${{ runner.temp }}/release-notes.md`, which is +now where the Action writes the body. diff --git a/changelog.d/+remove-upgrade-resolver.breaking.md b/changelog.d/+remove-upgrade-resolver.breaking.md index 55c7c63..fbaaa2c 100644 --- a/changelog.d/+remove-upgrade-resolver.breaking.md +++ b/changelog.d/+remove-upgrade-resolver.breaking.md @@ -1,2 +1,2 @@ -Remove the `upgrade-resolver` input of `refresh-release-lock` and `upgrade-release-tools`. The shared uv resolver pin -is upgraded only in github-workflows, which the Action now detects from the repository name. +Remove the `upgrade-resolver` input of `refresh-release-lock` and `upgrade-release-tools`. The shared uv resolver pin is +upgraded only in github-workflows, which the Action now detects from the repository name. diff --git a/render-release-notes/action.yml b/render-release-notes/action.yml index 050f998..85fce42 100644 --- a/render-release-notes/action.yml +++ b/render-release-notes/action.yml @@ -1,5 +1,5 @@ name: Render release notes -description: Validate a tagged changelog and render its GitHub Release body +description: Validate a tagged changelog and render its GitHub Release body to $RUNNER_TEMP/release-notes.md inputs: tag: description: Existing release tag @@ -13,9 +13,6 @@ inputs: artifact-label: description: Link text for the published artifact default: PyPI release - output: - description: Path for the rendered Markdown body - required: true generated-notes: description: Existing GitHub generated-notes file with contributor metadata default: "" @@ -49,7 +46,6 @@ runs: CHANGELOG: ${{ inputs.changelog }} ARTIFACT_URL: ${{ inputs.artifact-url }} ARTIFACT_LABEL: ${{ inputs.artifact-label }} - OUTPUT: ${{ inputs.output }} GENERATED_NOTES: ${{ inputs.generated-notes }} PREVIOUS: ${{ steps.previous.outputs.previous }} run: | @@ -64,4 +60,4 @@ runs: if [[ -n "$GENERATED_NOTES" ]]; then args+=(--generated-notes "$GENERATED_NOTES") fi - release-creator notes "${args[@]}" >"$OUTPUT" + release-creator notes "${args[@]}" >"$RUNNER_TEMP/release-notes.md" diff --git a/tests/render_release_notes/test_notes.py b/tests/render_release_notes/test_notes.py index ce35dad..8304e44 100644 --- a/tests/render_release_notes/test_notes.py +++ b/tests/render_release_notes/test_notes.py @@ -45,11 +45,10 @@ def test_render_release_notes_uses_tagged_changelog( "render-release-notes", version="0.2.0", TAG="0.2.0", - OUTPUT=str(tmp_path / "notes.md"), ARTIFACT_URL="https://pypi.org/project/fixture/0.2.0/", ) assert result.returncode == 0, result.stderr - assert (tmp_path / "notes.md").read_text() == dedent( + assert (tmp_path / "release-notes.md").read_text() == dedent( """\ @@ -81,11 +80,10 @@ def test_render_release_notes_uses_generated_contributors( "render-release-notes", version="0.2.0", TAG="0.2.0", - OUTPUT=str(tmp_path / "notes.md"), GENERATED_NOTES=str(generated), ) assert result.returncode == 0, result.stderr - assert "### New Contributors\n\n- @gaborbernat" in (tmp_path / "notes.md").read_text() + assert "### New Contributors\n\n- @gaborbernat" in (tmp_path / "release-notes.md").read_text() def test_render_release_notes_accepts_first_release( @@ -99,11 +97,10 @@ def test_render_release_notes_accepts_first_release( "render-release-notes", version="0.2.0", TAG="0.2.0", - OUTPUT=str(tmp_path / "notes.md"), ) assert result.returncode == 0, result.stderr - assert "### Features" in (tmp_path / "notes.md").read_text() - assert "compare/" not in (tmp_path / "notes.md").read_text() + assert "### Features" in (tmp_path / "release-notes.md").read_text() + assert "compare/" not in (tmp_path / "release-notes.md").read_text() @pytest.mark.parametrize( @@ -124,10 +121,12 @@ def test_render_release_notes_converts_rst_reference( version="0.2.0", TAG="0.2.0", CHANGELOG="CHANGELOG.rst", - OUTPUT=str(tmp_path / "notes.md"), ) assert result.returncode == 0, result.stderr - assert f"Correct a bug ([#4](https://github.com/tox-dev/fixture/{path}/4))" in (tmp_path / "notes.md").read_text() + assert ( + f"Correct a bug ([#4](https://github.com/tox-dev/fixture/{path}/4))" + in (tmp_path / "release-notes.md").read_text() + ) def test_render_release_notes_custom_artifact_label( @@ -140,14 +139,13 @@ def test_render_release_notes_custom_artifact_label( "render-release-notes", version="0.2.0", TAG="0.2.0", - OUTPUT=str(tmp_path / "notes.md"), ARTIFACT_URL="https://github.com/tox-dev/fixture/releases/download/0.2.0/fixture.pyz", ARTIFACT_LABEL="Zipapp", ) assert result.returncode == 0, result.stderr assert ( "[Zipapp](https://github.com/tox-dev/fixture/releases/download/0.2.0/fixture.pyz)" - in (tmp_path / "notes.md").read_text() + in (tmp_path / "release-notes.md").read_text() ) @@ -176,7 +174,6 @@ def test_render_release_notes_rejects_invalid_source( "render-release-notes", version=tag, TAG=tag, - OUTPUT=str(tmp_path / "notes.md"), ) assert result.returncode != 0 assert error in result.stderr From a9e48f95f87a45d7f777563e3ac66bd75b2d219d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sat, 24 Oct 2026 23:27:11 -0700 Subject: [PATCH 25/44] refactor(release)!: read the patch from a fixed path --- .github/workflows/prepare-towncrier-release.yaml | 2 -- README.md | 7 +++---- changelog.d/+fixed-patch-path.breaking.md | 2 ++ tests/conftest.py | 5 ++++- tests/release_workflow/test_graph.py | 3 +-- validate-release-patch/action.yml | 8 ++------ 6 files changed, 12 insertions(+), 15 deletions(-) create mode 100644 changelog.d/+fixed-patch-path.breaking.md diff --git a/.github/workflows/prepare-towncrier-release.yaml b/.github/workflows/prepare-towncrier-release.yaml index 87dfafc..01e577a 100644 --- a/.github/workflows/prepare-towncrier-release.yaml +++ b/.github/workflows/prepare-towncrier-release.yaml @@ -210,7 +210,6 @@ jobs: - uses: $/validate-release-patch with: version: ${{ needs.assemble.outputs.version }} - patch: ${{ runner.temp }}/release-patch/release.patch push: needs: [assemble, validate] runs-on: ubuntu-26.04 @@ -231,7 +230,6 @@ jobs: - uses: $/validate-release-patch with: version: ${{ needs.assemble.outputs.version }} - patch: ${{ runner.temp }}/release-patch/release.patch checks: "false" - id: repository name: Select the current repository diff --git a/README.md b/README.md index 6f6245b..83233b7 100644 --- a/README.md +++ b/README.md @@ -467,18 +467,17 @@ those changes. `validate-release-patch` runs in the caller's fresh checkout of the original branch commit. It rejects a `main` branch that moved, applies the uploaded patch, checks its changed paths and file modes, then asks `release-creator` to verify that every deleted file is a configured Towncrier fragment. It renders the requested version and runs `prek`. It leaves -the patch staged for the commit Action. Run it in a keyless validation job. The App-key job repeats the branch, path, -and mode checks with `checks: "false"`, without installing release tools. +the patch staged for the commit Action. Download the patch artifact to `${{ runner.temp }}/release-patch` first. Run it +in a keyless validation job. The App-key job repeats the branch, path, and mode checks with `checks: "false"`, without +installing release tools. ```yaml - uses: tox-dev/github-workflows/validate-release-patch@SHA with: version: ${{ needs.assemble.outputs.version }} - patch: ${{ runner.temp }}/release-patch/release.patch ``` - `version` **(required)**: Exact tag chosen by preparation. `release-creator notes` must find one valid section for it. -- `patch` **(required)**: Local path of the downloaded patch artifact, not an artifact name or URL. - `checks`: `true` (default) runs `release-creator` and `prek`; `false` only applies and checks the validated patch. **Output:** No named output. **Working-tree effect:** Applies and stages the patch. The Action reads the changelog path, diff --git a/changelog.d/+fixed-patch-path.breaking.md b/changelog.d/+fixed-patch-path.breaking.md new file mode 100644 index 0000000..7b39a4f --- /dev/null +++ b/changelog.d/+fixed-patch-path.breaking.md @@ -0,0 +1,2 @@ +Remove the `validate-release-patch` `patch` input. The Action reads the patch from +`${{ runner.temp }}/release-patch/release.patch`, where every caller downloaded it. diff --git a/tests/conftest.py b/tests/conftest.py index 5b7b3f6..0393145 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,6 +2,7 @@ import json import os +import shutil import ssl import subprocess import sys @@ -362,6 +363,9 @@ def action( and not (repository / "pyproject.toml").exists() ): (repository / "pyproject.toml").write_text('[tool.nab]\nuploaded-prior-to = "P7D"\n') + if name == "validate-release-patch" and (prepared := tmp_path / "release.patch").exists(): + (tmp_path / "release-patch").mkdir(exist_ok=True) + shutil.copyfile(prepared, tmp_path / "release-patch/release.patch") action_config = yaml.safe_load((Path(__file__).resolve().parents[1] / name / "action.yml").read_text()) head = run(repository, "git", "rev-parse", "HEAD").stdout.strip() if (repository / ".git").exists() else "0" * 40 input_values = { @@ -372,7 +376,6 @@ def action( "CHANGELOG": "CHANGELOG.md", "FRAGMENTS": "changelog.d", "FORMAT": action_config.get("inputs", {}).get("format", {}).get("default", "none"), - "PATCH": str(tmp_path / "release.patch"), "EXPECTED_SHA": head, "CHECKS": "true", "BRANCH": "main", diff --git a/tests/release_workflow/test_graph.py b/tests/release_workflow/test_graph.py index 6a2af08..28ca01b 100644 --- a/tests/release_workflow/test_graph.py +++ b/tests/release_workflow/test_graph.py @@ -72,8 +72,7 @@ def test_prepare_validates_uploaded_patch_before_writer() -> None: assert jobs["validate"]["needs"] == "assemble" assert jobs["push"]["needs"] == ["assemble", "validate"] assert not any(step.get("uses") == "$/validate-release-patch" for step in jobs["assemble"]["steps"]) - validation = next(step for step in jobs["validate"]["steps"] if step.get("uses") == "$/validate-release-patch") - assert validation["with"]["patch"] == "${{ runner.temp }}/release-patch/release.patch" + assert any(step.get("uses") == "$/validate-release-patch" for step in jobs["validate"]["steps"]) assert ( next(step for step in jobs["push"]["steps"] if step.get("uses") == "$/validate-release-patch")["with"]["checks"] == "false" diff --git a/validate-release-patch/action.yml b/validate-release-patch/action.yml index 74eaddb..65a3545 100644 --- a/validate-release-patch/action.yml +++ b/validate-release-patch/action.yml @@ -1,12 +1,9 @@ name: Validate release patch -description: Check and apply a prepared release patch, with optional release-tool validation +description: Check and apply the release patch downloaded to $RUNNER_TEMP/release-patch, with optional release-tool validation inputs: version: description: Release version required: true - patch: - description: Path to the downloaded patch - required: true checks: description: Run release-creator and prek after applying the patch default: "true" @@ -25,7 +22,6 @@ runs: - name: Apply release patch shell: bash env: - PATCH: ${{ inputs.patch }} CHECKS: ${{ inputs.checks }} run: | set -euo pipefail @@ -33,7 +29,7 @@ runs: echo '::error::checks must be true or false' >&2 exit 1 fi - git apply --index "$PATCH" + git apply --index "$RUNNER_TEMP/release-patch/release.patch" - id: config uses: $/read-towncrier-config - name: Check release patch paths From 4768364a08614c72f8af253ec41549af4b773d3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sat, 24 Oct 2026 23:29:37 -0700 Subject: [PATCH 26/44] refactor(pypi): inline the listing wait --- tests/verify_pypi_files/test_verify.py | 88 ++++++++++++++------------ tools/verify_pypi_files.py | 11 ++-- 2 files changed, 54 insertions(+), 45 deletions(-) diff --git a/tests/verify_pypi_files/test_verify.py b/tests/verify_pypi_files/test_verify.py index 3ffbd25..3ff7aae 100644 --- a/tests/verify_pypi_files/test_verify.py +++ b/tests/verify_pypi_files/test_verify.py @@ -3,6 +3,7 @@ import hashlib import importlib.util import io +import itertools import json import runpy import ssl @@ -27,7 +28,7 @@ assert _SPEC is not None and _SPEC.loader is not None _MODULE = importlib.util.module_from_spec(_SPEC) _SPEC.loader.exec_module(_MODULE) -verify_files = cast("Callable[[str, str, Path, int], list[Path]]", _MODULE.verify_files) +verify_files = cast("Callable[[str, str, Path], list[Path]]", _MODULE.verify_files) type _Json = None | bool | int | float | str | list[_Json] | dict[str, _Json] @@ -38,6 +39,17 @@ class _ListingFile(TypedDict): yanked: bool | str +@pytest.fixture(autouse=True) +def expired_deadline(mocker: MockerFixture) -> None: + mocker.patch.object(_MODULE.time, "monotonic", side_effect=itertools.count(step=121)) + mocker.patch.object(_MODULE.time, "sleep") + + +@pytest.fixture +def waiting_clock(mocker: MockerFixture) -> None: + mocker.patch.object(_MODULE.time, "monotonic", return_value=0) + + @pytest.fixture def distribution(tmp_path: Path) -> Path: directory = tmp_path / "dist" @@ -50,7 +62,7 @@ def distribution(tmp_path: Path) -> Path: def test_verify_files_matches_complete_release(distribution: Path, mocker: MockerFixture) -> None: _serve(mocker, distribution) - result = verify_files("fixture", "1.0", distribution, 0) + result = verify_files("fixture", "1.0", distribution) assert result == [distribution / "fixture-1.0-py3-none-any.whl", distribution / "fixture-1.0.tar.gz"] @@ -59,7 +71,7 @@ def test_verify_files_uses_pinned_trust_store(distribution: Path, mocker: Mocker request = mocker.patch.object(_MODULE, "urlopen", return_value=_body(_listing(distribution))) context = mocker.patch.object(ssl, "create_default_context", autospec=True) - verify_files("fixture", "1.0", distribution, 0) + verify_files("fixture", "1.0", distribution) assert request.call_args.kwargs["context"] is context.return_value assert context.call_args.kwargs == {"cafile": certifi.where()} @@ -72,7 +84,7 @@ def test_verify_files_rejects_unsupported_simple_api(distribution: Path, mocker: return_value=io.BytesIO(json.dumps({"meta": {"api-version": "2.0"}, "files": _listing(distribution)}).encode()), ) with pytest.raises(ValueError, match="unsupported PyPI Simple API version"): - verify_files("fixture", "1.0", distribution, 0) + verify_files("fixture", "1.0", distribution) def test_main_prints_distribution_paths( @@ -98,7 +110,7 @@ def test_main_reports_invalid_directory( cast("Callable[[], None]", _MODULE.main)() assert raised.value.code == 2 - assert "release files require a directory" in capsys.readouterr().err + assert "distribution directory does not exist" in capsys.readouterr().err def test_script_entrypoint_prints_distribution_paths( @@ -115,26 +127,21 @@ def test_script_entrypoint_prints_distribution_paths( ] -@pytest.mark.parametrize( - "change", [pytest.param("negative-wait", id="negative-wait"), pytest.param("missing-dir", id="missing-dir")] -) -def test_verify_files_rejects_invalid_arguments(tmp_path: Path, change: str) -> None: - directory = tmp_path if change == "negative-wait" else tmp_path / "missing" - - with pytest.raises(ValueError, match="release files require a directory and nonnegative wait"): - verify_files("fixture", "1.0", directory, -1 if change == "negative-wait" else 0) +def test_verify_files_rejects_missing_directory(tmp_path: Path) -> None: + with pytest.raises(ValueError, match="distribution directory does not exist"): + verify_files("fixture", "1.0", tmp_path / "missing") def test_verify_files_rejects_empty_directory(tmp_path: Path) -> None: with pytest.raises(ValueError, match="distribution directory is empty"): - verify_files("fixture", "1.0", tmp_path, 0) + verify_files("fixture", "1.0", tmp_path) def test_verify_files_rejects_directory_entry(distribution: Path) -> None: (distribution / "nested").mkdir() with pytest.raises(ValueError, match="invalid distribution path"): - verify_files("fixture", "1.0", distribution, 0) + verify_files("fixture", "1.0", distribution) def test_verify_files_ignores_other_release_wheel(distribution: Path, mocker: MockerFixture) -> None: @@ -142,7 +149,7 @@ def test_verify_files_ignores_other_release_wheel(distribution: Path, mocker: Mo files.append({"filename": "fixture-0.9-py3-none-any.whl", "hashes": {"sha256": "0" * 64}, "yanked": False}) mocker.patch.object(_MODULE, "urlopen", return_value=_body(files)) - assert verify_files("fixture", "1.0", distribution, 0) == [ + assert verify_files("fixture", "1.0", distribution) == [ distribution / "fixture-1.0-py3-none-any.whl", distribution / "fixture-1.0.tar.gz", ] @@ -162,7 +169,7 @@ def test_verify_files_propagates_http_error(distribution: Path, mocker: MockerFi ) with pytest.raises(HTTPError) as raised: - verify_files("fixture", "1.0", distribution, 0) + verify_files("fixture", "1.0", distribution) assert raised.value.code == status @@ -171,7 +178,7 @@ def test_verify_files_propagates_exhausted_connection_error(distribution: Path, mocker.patch.object(_MODULE, "urlopen", side_effect=URLError("offline")) with pytest.raises(URLError, match="offline"): - verify_files("fixture", "1.0", distribution, 0) + verify_files("fixture", "1.0", distribution) def test_verify_files_reports_missing_release_after_404(distribution: Path, mocker: MockerFixture) -> None: @@ -180,7 +187,7 @@ def test_verify_files_reports_missing_release_after_404(distribution: Path, mock ) with pytest.raises(ValueError, match="PyPI is missing files"): - verify_files("fixture", "1.0", distribution, 0) + verify_files("fixture", "1.0", distribution) @pytest.mark.parametrize( @@ -198,7 +205,7 @@ def test_verify_files_rejects_invalid_listing(distribution: Path, mocker: Mocker with pytest.raises( (TypeError, ValueError), match="invalid Simple API response|unsupported PyPI Simple API version" ): - verify_files("fixture", "1.0", distribution, 0) + verify_files("fixture", "1.0", distribution) @pytest.mark.parametrize( @@ -218,7 +225,7 @@ def test_verify_files_rejects_invalid_file_entry(distribution: Path, mocker: Moc ) with pytest.raises(TypeError, match="invalid file entry"): - verify_files("fixture", "1.0", distribution, 0) + verify_files("fixture", "1.0", distribution) def test_verify_files_ignores_legacy_egg_from_older_release(distribution: Path, mocker: MockerFixture) -> None: @@ -226,7 +233,7 @@ def test_verify_files_ignores_legacy_egg_from_older_release(distribution: Path, files.append({"filename": "fixture-0.8.1-py2.4.egg", "hashes": {"sha256": "0" * 64}, "yanked": False}) mocker.patch.object(_MODULE, "urlopen", return_value=_body(files)) - assert verify_files("fixture", "1.0", distribution, 0) == [ + assert verify_files("fixture", "1.0", distribution) == [ distribution / "fixture-1.0-py3-none-any.whl", distribution / "fixture-1.0.tar.gz", ] @@ -249,7 +256,7 @@ def test_verify_files_ignores_legacy_files_from_other_versions( files.append({"filename": filename, "hashes": {"sha256": "0" * 64}, "yanked": False}) mocker.patch.object(_MODULE, "urlopen", return_value=_body(files)) - assert verify_files("fixture", "0.9", distribution, 0) == [ + assert verify_files("fixture", "0.9", distribution) == [ distribution / "fixture-0.9-py3-none-any.whl", distribution / "fixture-0.9.tar.gz", ] @@ -273,7 +280,7 @@ def test_verify_files_rejects_bad_remote_release( _serve(mocker, distribution, change=change) with pytest.raises(ValueError, match=message): - verify_files("fixture", "1.0", distribution, 0) + verify_files("fixture", "1.0", distribution) @pytest.mark.parametrize( @@ -293,19 +300,20 @@ def test_verify_files_rejects_bad_local_release(distribution: Path, change: str, (distribution / ".hidden").write_bytes(b"other") with pytest.raises(ValueError, match=message): - verify_files("fixture", "1.0", distribution, 0) + verify_files("fixture", "1.0", distribution) -def test_verify_files_waits_for_incomplete_listing(distribution: Path, mocker: MockerFixture) -> None: +def test_verify_files_waits_for_incomplete_listing( + distribution: Path, mocker: MockerFixture, waiting_clock: None +) -> None: files = _listing(distribution) response = mocker.patch.object( _MODULE, "urlopen", side_effect=[_body(files[:-1]), _body(files)], ) - mocker.patch.object(_MODULE.time, "sleep") - result = verify_files("fixture", "1.0", distribution, 2) + result = verify_files("fixture", "1.0", distribution) assert result == [distribution / "fixture-1.0-py3-none-any.whl", distribution / "fixture-1.0.tar.gz"] assert response.call_count == 2 @@ -330,12 +338,14 @@ def test_verify_files_waits_for_incomplete_listing(distribution: Path, mocker: M ], ) def test_verify_files_retries_transient_listing_failure( - distribution: Path, mocker: MockerFixture, failure: OSError | IncompleteRead | json.JSONDecodeError + distribution: Path, + mocker: MockerFixture, + failure: OSError | IncompleteRead | json.JSONDecodeError, + waiting_clock: None, ) -> None: response = mocker.patch.object(_MODULE, "urlopen", side_effect=[failure, _body(_listing(distribution))]) - mocker.patch.object(_MODULE.time, "sleep") - assert verify_files("fixture", "1.0", distribution, 2) == [ + assert verify_files("fixture", "1.0", distribution) == [ distribution / "fixture-1.0-py3-none-any.whl", distribution / "fixture-1.0.tar.gz", ] @@ -350,26 +360,26 @@ def test_verify_files_retries_transient_listing_failure( ], ) def test_verify_files_retries_response_read_failure( - distribution: Path, mocker: MockerFixture, failure: TimeoutError | IncompleteRead + distribution: Path, mocker: MockerFixture, failure: TimeoutError | IncompleteRead, waiting_clock: None ) -> None: response = mocker.create_autospec(io.BytesIO, instance=True) 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") - assert verify_files("fixture", "1.0", distribution, 2) == [ + assert verify_files("fixture", "1.0", distribution) == [ distribution / "fixture-1.0-py3-none-any.whl", distribution / "fixture-1.0.tar.gz", ] -def test_verify_files_retries_truncated_json_response(distribution: Path, mocker: MockerFixture) -> None: +def test_verify_files_retries_truncated_json_response( + distribution: Path, mocker: MockerFixture, waiting_clock: None +) -> None: response = mocker.patch.object( _MODULE, "urlopen", side_effect=[io.BytesIO(b'{"meta":'), _body(_listing(distribution))] ) - mocker.patch.object(_MODULE.time, "sleep") - assert verify_files("fixture", "1.0", distribution, 2) == [ + assert verify_files("fixture", "1.0", distribution) == [ distribution / "fixture-1.0-py3-none-any.whl", distribution / "fixture-1.0.tar.gz", ] @@ -390,7 +400,7 @@ def test_main_reports_exhausted_incomplete_read( assert "IncompleteRead" in capsys.readouterr().err -def test_verify_files_backs_off_between_retries(distribution: Path, mocker: MockerFixture) -> None: +def test_verify_files_backs_off_between_retries(distribution: Path, mocker: MockerFixture, waiting_clock: None) -> None: mocker.patch.object( _MODULE, "urlopen", @@ -402,7 +412,7 @@ def test_verify_files_backs_off_between_retries(distribution: Path, mocker: Mock ) sleep = mocker.patch.object(_MODULE.time, "sleep") - verify_files("fixture", "1.0", distribution, 5) + verify_files("fixture", "1.0", distribution) assert [call.args for call in sleep.call_args_list] == [(1,), (2,)] diff --git a/tools/verify_pypi_files.py b/tools/verify_pypi_files.py index ea7c970..a76cf68 100644 --- a/tools/verify_pypi_files.py +++ b/tools/verify_pypi_files.py @@ -37,23 +37,22 @@ def main() -> None: parser.add_argument("dist", type=Path) args = parser.parse_args() try: - for path in verify_files(args.project, args.version, args.dist, 120): + for path in verify_files(args.project, args.version, args.dist): print(path) except (OSError, TypeError, ValueError, IncompleteRead) as error: parser.error(str(error)) -def verify_files(project: str, version: str, dist: Path, wait_seconds: int) -> list[Path]: +def verify_files(project: str, version: str, dist: Path) -> list[Path]: """Check the local files against PyPI. :param project: Published project name. :param version: Published release version. :param dist: Directory containing every expected distribution. - :param wait_seconds: Maximum time to wait for PyPI's listing. :returns: Local distribution paths for attestation verification. """ - if wait_seconds < 0 or not dist.is_dir(): - raise ValueError("release files require a directory and nonnegative wait") + if not dist.is_dir(): + raise ValueError(f"distribution directory does not exist: {dist}") name = canonicalize_name(project, validate=True) release = Version(version) local: dict[str, str] = {} @@ -66,7 +65,7 @@ def verify_files(project: str, version: str, dist: Path, wait_seconds: int) -> l local[path.name] = hashlib.file_digest(stream, "sha256").hexdigest() if not local: raise ValueError(f"distribution directory is empty: {dist}") - deadline = time.monotonic() + wait_seconds + deadline = time.monotonic() + 120 delay = 1 while True: remote: dict[str, str] = {} From 4b3d58a864e2b1b1d1608f95c392ecb02e39de19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sat, 24 Oct 2026 23:30:38 -0700 Subject: [PATCH 27/44] refactor(lock): drop checks the parser already makes --- tests/refresh_release_lock/test_cutoffs.py | 2 +- tools/check_lock_groups.py | 4 ---- tools/nab_cutoffs.py | 3 --- 3 files changed, 1 insertion(+), 8 deletions(-) diff --git a/tests/refresh_release_lock/test_cutoffs.py b/tests/refresh_release_lock/test_cutoffs.py index 24020cb..1f66d36 100644 --- a/tests/refresh_release_lock/test_cutoffs.py +++ b/tests/refresh_release_lock/test_cutoffs.py @@ -598,7 +598,7 @@ def test_recent_cutoff_rejects_index_override() -> None: "[tool.nab.index.pypi]\nuploaded-prior-to = false\n" ) assert result.returncode != 0 - assert "Nab index cutoff for pypi" in result.stderr + assert "custom Nab indexes are not approved" in result.stderr def test_recent_cutoff_rejects_custom_index() -> None: diff --git a/tools/check_lock_groups.py b/tools/check_lock_groups.py index d1940a3..a2c7bd4 100644 --- a/tools/check_lock_groups.py +++ b/tools/check_lock_groups.py @@ -169,10 +169,6 @@ def _selected_packages(lock: Pylock, groups: set[str], *, local_tool_source: boo and package.directory is not None and package.directory.path == "." and package.index is None - and package.archive is None - and package.vcs is None - and package.sdist is None - and (package.wheels is None or len(package.wheels) == 0) ): continue yield package diff --git a/tools/nab_cutoffs.py b/tools/nab_cutoffs.py index 60cfdbc..d08c27f 100644 --- a/tools/nab_cutoffs.py +++ b/tools/nab_cutoffs.py @@ -56,9 +56,6 @@ 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") validate_indexes(nab) for name, value in candidates: if value is False: From 3df4112e2bd9682b8436b0685547d793d96fe296 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sat, 24 Oct 2026 23:30:38 -0700 Subject: [PATCH 28/44] test(lock): bound the local tool exemption --- tests/install_locked_tools/test_metadata.py | 24 +++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/install_locked_tools/test_metadata.py b/tests/install_locked_tools/test_metadata.py index ae4fe07..d0acd42 100644 --- a/tests/install_locked_tools/test_metadata.py +++ b/tests/install_locked_tools/test_metadata.py @@ -334,6 +334,30 @@ def test_lock_metadata_allows_local_release_creator( _run_check(lock_case, monkeypatch, "--local-tool-source", "--verify-pypi", "--project", str(lock_case[1])) +@pytest.mark.parametrize( + ("name", "path"), + [pytest.param("release-creator", "../other", id="other-directory"), pytest.param("six", ".", id="other-package")], +) +def test_lock_metadata_limits_local_tool_exemption( + lock_case: tuple[Path, Path], + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + name: str, + path: str, +) -> None: + lock = lock_case[0] + lock.write_text( + lock.read_text().split("[[packages]]", 1)[0] + + f'[[packages]]\nname = "{name}"\nmarker = \'"release-check" in dependency_groups\'\n' + + f'directory = {{ path = "{path}" }}\n' + ) + + with pytest.raises(SystemExit, match="2"): + _run_check(lock_case, monkeypatch, "--local-tool-source") + + assert "direct-source package" in capsys.readouterr().err + + @pytest.mark.parametrize( ("source", "message"), [ From 5abe71997d264016ad941226d513f1f71a85e163 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sat, 24 Oct 2026 23:31:56 -0700 Subject: [PATCH 29/44] test(pypi): match the missing directory error --- tests/reconcile_pypi_artifacts/test_reconcile.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/reconcile_pypi_artifacts/test_reconcile.py b/tests/reconcile_pypi_artifacts/test_reconcile.py index 1b2cc7f..5b9b38e 100644 --- a/tests/reconcile_pypi_artifacts/test_reconcile.py +++ b/tests/reconcile_pypi_artifacts/test_reconcile.py @@ -14,7 +14,7 @@ [ pytest.param("", "1.0", True, "name is invalid", id="missing-project"), pytest.param("fixture", "", True, "Invalid version", id="missing-version"), - pytest.param("fixture", "1.0", False, "release files require a directory", id="missing-distribution"), + pytest.param("fixture", "1.0", False, "distribution directory does not exist", id="missing-distribution"), ], ) def test_reconcile_rejects_invalid_inputs( From 687923dc57a34e466729b9d6e06f255d425f1728 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sat, 24 Oct 2026 23:32:09 -0700 Subject: [PATCH 30/44] perf(lock): skip checks repeated on the same lock --- .../+skip-shared-lock-recheck.performance.md | 2 ++ install-locked-tools/action.yml | 31 +++++++++---------- 2 files changed, 17 insertions(+), 16 deletions(-) create mode 100644 changelog.d/+skip-shared-lock-recheck.performance.md diff --git a/changelog.d/+skip-shared-lock-recheck.performance.md b/changelog.d/+skip-shared-lock-recheck.performance.md new file mode 100644 index 0000000..7fadb54 --- /dev/null +++ b/changelog.d/+skip-shared-lock-recheck.performance.md @@ -0,0 +1,2 @@ +Skip the cooling check of github-workflows' own lock when installing shared tools, and the second PyPI check of a +secondary lock's build selection when `export-build-constraints` already verified that lock. diff --git a/install-locked-tools/action.yml b/install-locked-tools/action.yml index f626c0a..abef02d 100644 --- a/install-locked-tools/action.yml +++ b/install-locked-tools/action.yml @@ -62,14 +62,8 @@ runs: run: | set -euo pipefail case "$LOCK_SOURCE" in - caller) - lock=pylock.toml - lock_check=(--shared-lock "$GITHUB_ACTION_PATH/../pylock.toml" --verify-pypi --project pyproject.toml) - ;; - shared) - lock="$GITHUB_ACTION_PATH/../pylock.toml" - lock_check=() - ;; + caller) lock=pylock.toml ;; + shared) lock="$GITHUB_ACTION_PATH/../pylock.toml" ;; *) echo "::error::unknown lock source: $LOCK_SOURCE" >&2 exit 1 @@ -86,16 +80,19 @@ runs: exit 1 } uv venv --allow-existing --python 3.15 "$VENV" - source_check=() - if [[ "$LOCAL_TOOL_SOURCE" == true ]]; then source_check=(--local-tool-source); fi - uv run --no-project --python "$RUNNER_TEMP/lock-reader" python \ - "$GITHUB_ACTION_PATH/../tools/check_lock_groups.py" "$lock" "${groups[@]}" \ - "${lock_check[@]}" "${source_check[@]}" + selection=("${groups[@]}") printf '%s\n' "${groups[@]}" >"$RUNNER_TEMP/selected-lock-groups" else + selection=(--build-constraints) + fi + # The shared lock is checked in github-workflows CI; only a caller lock needs the cooling and PyPI checks. + if [[ "$LOCK_SOURCE" == caller ]]; then + source_check=() + if [[ "$LOCAL_TOOL_SOURCE" == true ]]; then source_check=(--local-tool-source); fi uv run --no-project --python "$RUNNER_TEMP/lock-reader" python \ - "$GITHUB_ACTION_PATH/../tools/check_lock_groups.py" "$lock" --build-constraints \ - "${lock_check[@]}" + "$GITHUB_ACTION_PATH/../tools/check_lock_groups.py" "$lock" "${selection[@]}" \ + --shared-lock "$GITHUB_ACTION_PATH/../pylock.toml" --verify-pypi --project pyproject.toml \ + "${source_check[@]}" fi printf 'lock=%s\n' "$lock" >>"$GITHUB_OUTPUT" - name: Verify additional release lock @@ -124,11 +121,13 @@ runs: env: BUILD_LOCK: ${{ steps.selection.outputs.lock }} CONSTRAINTS_LOCK: ${{ inputs.constraints-lock }} + VERIFY_LOCK: ${{ inputs.verify-lock }} TARGET_PLATFORM: ${{ inputs.target-platform }} TARGETS: ${{ inputs.targets }} run: | set -euo pipefail - if [[ -n "$CONSTRAINTS_LOCK" && "$CONSTRAINTS_LOCK" != "$BUILD_LOCK" ]]; then + # A verified lock already had its build selection checked against PyPI with its groups. + if [[ -n "$CONSTRAINTS_LOCK" && "$CONSTRAINTS_LOCK" != "$BUILD_LOCK" && "$CONSTRAINTS_LOCK" != "$VERIFY_LOCK" ]]; then lock_check=() if [[ "$CONSTRAINTS_LOCK" != "$GITHUB_ACTION_PATH/../pylock.toml" ]]; then lock_check=(--verify-pypi --project pyproject.toml) From adc7bb28303e74fc876e9616df71b4b3bc279eef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sat, 24 Oct 2026 23:37:17 -0700 Subject: [PATCH 31/44] refactor(release): share the tagged source check --- .github/workflows/check.yaml | 21 +--- .github/workflows/release.yaml | 28 +---- .../workflows/validate-python-release.yaml | 27 +--- changelog.d/+check-tagged-source.feature.md | 2 + check-tagged-source/action.yml | 55 +++++++++ prepare-towncrier/action.yml | 11 +- .../test_workflow.py | 115 ++++++++++-------- tests/release_workflow/test_graph.py | 21 ---- tests/validate_python_release/test_tagged.py | 79 ++++-------- tools/cooled_prek.sh | 8 ++ validate-release-patch/action.yml | 10 +- 11 files changed, 164 insertions(+), 213 deletions(-) create mode 100644 changelog.d/+check-tagged-source.feature.md create mode 100644 check-tagged-source/action.yml create mode 100755 tools/cooled_prek.sh diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index e5aa28a..2fe0b20 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -38,15 +38,7 @@ jobs: groups: release-check venv: .release-tools - name: Check repository formatting - env: - UV_EXCLUDE_NEWER: 7 days - UV_NO_CONFIG: "1" - UV_NO_SOURCES: "1" - run: | - unset UV_CONFIG_FILE UV_INDEX UV_INDEX_URL UV_DEFAULT_INDEX UV_EXTRA_INDEX_URL - unset UV_FIND_LINKS UV_EXCLUDE_NEWER_PACKAGE - unset UV_INSECURE_HOST UV_SYSTEM_CERTS SSL_CERT_FILE SSL_CERT_DIR - prek run --config .pre-commit-config.yaml --all-files + run: tools/cooled_prek.sh run --all-files shell: runs-on: ubuntu-26.04 permissions: @@ -74,14 +66,7 @@ jobs: groups: release-shell venv: .release-tools - name: Check inline shell - env: - UV_EXCLUDE_NEWER: 7 days - UV_NO_CONFIG: "1" - UV_NO_SOURCES: "1" run: | - unset UV_CONFIG_FILE UV_INDEX UV_INDEX_URL UV_DEFAULT_INDEX UV_EXTRA_INDEX_URL - unset UV_FIND_LINKS UV_EXCLUDE_NEWER_PACKAGE - unset UV_INSECURE_HOST UV_SYSTEM_CERTS SSL_CERT_FILE SSL_CERT_DIR set -euo pipefail script_dir=./inline-shell-check mkdir "$script_dir" @@ -96,8 +81,8 @@ jobs: index=$((index + 1)) done <"$RUNNER_TEMP/run-blocks" done - prek run --config .pre-commit-config.yaml shellcheck --files "$script_dir"/*.sh - prek run --config .pre-commit-config.yaml shfmt --files "$script_dir"/*.sh + tools/cooled_prek.sh run shellcheck --files "$script_dir"/*.sh + tools/cooled_prek.sh run shfmt --files "$script_dir"/*.sh test: runs-on: ubuntu-26.04 permissions: diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index f33b412..8ad693f 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -19,38 +19,12 @@ jobs: with: fetch-depth: 0 persist-credentials: false - - name: Verify the exact release tag - env: - TAG: ${{ github.ref_name }} - run: | - set -euo pipefail - test "$GITHUB_REF" = "refs/tags/$TAG" - git check-ref-format "refs/tags/$TAG" - test "$(git rev-parse "refs/tags/$TAG^{commit}")" = "$(git rev-parse HEAD)" - git merge-base --is-ancestor HEAD origin/main - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 with: version: latest-known python-version: "3.15" enable-cache: false - - uses: $/refresh-release-lock - with: - mode: check - - uses: $/install-locked-tools - with: - groups: release-check - venv: .venv - - uses: $/verify-frozen-hooks - - name: Check tagged source - env: - UV_EXCLUDE_NEWER: 7 days - UV_NO_CONFIG: "1" - UV_NO_SOURCES: "1" - run: | - unset UV_CONFIG_FILE UV_INDEX UV_INDEX_URL UV_DEFAULT_INDEX UV_EXTRA_INDEX_URL - unset UV_FIND_LINKS UV_EXCLUDE_NEWER_PACKAGE - unset UV_INSECURE_HOST UV_SYSTEM_CERTS SSL_CERT_FILE SSL_CERT_DIR - .venv/bin/prek run --config .pre-commit-config.yaml --all-files + - uses: $/check-tagged-source source: runs-on: ubuntu-26.04 permissions: diff --git a/.github/workflows/validate-python-release.yaml b/.github/workflows/validate-python-release.yaml index c93415a..de1d9f2 100644 --- a/.github/workflows/validate-python-release.yaml +++ b/.github/workflows/validate-python-release.yaml @@ -57,23 +57,13 @@ jobs: fetch-depth: 0 persist-credentials: false ref: ${{ github.sha }} - - name: Verify the exact release tag - env: - TAG: ${{ github.ref_name }} - run: | - set -euo pipefail - test "$GITHUB_REF" = "refs/tags/$TAG" - git check-ref-format "refs/tags/$TAG" - test "$(git rev-parse "refs/tags/$TAG^{commit}")" = "$(git rev-parse HEAD)" - git merge-base --is-ancestor HEAD origin/main - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 with: version: latest-known python-version: "3.15" enable-cache: false - - uses: $/refresh-release-lock + - uses: $/check-tagged-source with: - mode: check keep-uv-lock: ${{ inputs.keep-uv-lock }} additional-lock: ${{ inputs.additional-lock }} additional-lock-groups: ${{ inputs.additional-lock-groups }} @@ -81,21 +71,6 @@ jobs: additional-matrix-python: ${{ inputs.additional-matrix-python }} additional-matrix-platforms: ${{ inputs.additional-matrix-platforms }} additional-matrix-implementations: ${{ inputs.additional-matrix-implementations }} - - uses: $/install-locked-tools - with: - groups: release-check - venv: ${{ runner.temp }}/release-tools - - uses: $/verify-frozen-hooks - - name: Run tagged source checks - env: - UV_EXCLUDE_NEWER: 7 days - UV_NO_CONFIG: "1" - UV_NO_SOURCES: "1" - run: | - unset UV_CONFIG_FILE UV_INDEX UV_INDEX_URL UV_DEFAULT_INDEX UV_EXTRA_INDEX_URL - unset UV_FIND_LINKS UV_EXCLUDE_NEWER_PACKAGE - unset UV_INSECURE_HOST UV_SYSTEM_CERTS SSL_CERT_FILE SSL_CERT_DIR - "$RUNNER_TEMP/release-tools/bin/prek" run --config .pre-commit-config.yaml --all-files constraints: runs-on: ubuntu-26.04 outputs: diff --git a/changelog.d/+check-tagged-source.feature.md b/changelog.d/+check-tagged-source.feature.md new file mode 100644 index 0000000..30061b2 --- /dev/null +++ b/changelog.d/+check-tagged-source.feature.md @@ -0,0 +1,2 @@ +Add `check-tagged-source`, which runs the tag, lock, frozen hook, and cooled `prek` checks of a tagged checkout. The +Python release workflow uses it, and custom tag release jobs can call it before publishing. diff --git a/check-tagged-source/action.yml b/check-tagged-source/action.yml new file mode 100644 index 0000000..60a40a5 --- /dev/null +++ b/check-tagged-source/action.yml @@ -0,0 +1,55 @@ +name: Check tagged source +description: Check the tag, the project lock, and the frozen hooks of a tagged checkout, then run its cooled hooks +inputs: + keep-uv-lock: + description: Check a committed native uv.lock alongside pylock.toml + default: "false" + additional-lock: + description: Secondary Nab lock to check against the tagged project + default: "" + additional-lock-groups: + description: Groups selected by the secondary lock + default: "" + additional-lock-selection: + description: Project or build-or-groups selection for the secondary lock + default: build-or-groups + additional-matrix-python: + description: Python range for the secondary lock + default: "" + additional-matrix-platforms: + description: Space-separated Nab platform IDs for the secondary lock + default: "" + additional-matrix-implementations: + description: Space-separated implementations for the secondary lock + default: "" +runs: + using: composite + steps: + - name: Verify the exact release tag + shell: bash + env: + TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + test "$GITHUB_REF" = "refs/tags/$TAG" + git check-ref-format "refs/tags/$TAG" + test "$(git rev-parse "refs/tags/$TAG^{commit}")" = "$(git rev-parse HEAD)" + git merge-base --is-ancestor HEAD origin/main + - uses: $/refresh-release-lock + with: + mode: check + keep-uv-lock: ${{ inputs.keep-uv-lock }} + 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 }} + - uses: $/install-locked-tools + with: + groups: release-check + venv: ${{ runner.temp }}/source-check-tools + - uses: $/verify-frozen-hooks + - name: Run tagged source hooks + shell: bash + run: '"$GITHUB_ACTION_PATH/../tools/cooled_prek.sh" run --all-files' diff --git a/prepare-towncrier/action.yml b/prepare-towncrier/action.yml index 26fc8c8..82edc19 100644 --- a/prepare-towncrier/action.yml +++ b/prepare-towncrier/action.yml @@ -99,13 +99,7 @@ runs: env: CHANGELOG: ${{ steps.config.outputs.changelog }} FORMAT: ${{ steps.config.outputs.format }} - UV_EXCLUDE_NEWER: 7 days - UV_NO_CONFIG: "1" - UV_NO_SOURCES: "1" run: | - unset UV_CONFIG_FILE UV_INDEX UV_INDEX_URL UV_DEFAULT_INDEX UV_EXTRA_INDEX_URL - unset UV_FIND_LINKS UV_EXCLUDE_NEWER_PACKAGE - unset UV_INSECURE_HOST UV_SYSTEM_CERTS SSL_CERT_FILE SSL_CERT_DIR set -euo pipefail case "$FORMAT" in markdown) formatter=mdformat ;; @@ -115,8 +109,9 @@ runs: exit 1 ;; esac - if ! prek run --config .pre-commit-config.yaml "$formatter" --files "$CHANGELOG"; then - prek run --config .pre-commit-config.yaml "$formatter" --files "$CHANGELOG" + prek=$GITHUB_ACTION_PATH/../tools/cooled_prek.sh + if ! "$prek" run "$formatter" --files "$CHANGELOG"; then + "$prek" run "$formatter" --files "$CHANGELOG" fi - name: Check generated files if: inputs.phase != 'build' diff --git a/tests/check_hook_update_cooldown/test_workflow.py b/tests/check_hook_update_cooldown/test_workflow.py index 0427385..a3ded9f 100644 --- a/tests/check_hook_update_cooldown/test_workflow.py +++ b/tests/check_hook_update_cooldown/test_workflow.py @@ -2,6 +2,7 @@ import json import os +import re import subprocess from pathlib import Path @@ -54,59 +55,75 @@ def test_required_gate_does_not_repeat_changelog_lock_check() -> None: assert news["jobs"]["changelog"]["steps"][-1]["uses"] == "$/validate-news" -@pytest.mark.parametrize( - ("workflow_name", "job_name"), - [ - pytest.param("prepare-towncrier-release", "assemble", id="prepare"), - pytest.param("validate-python-release", "source-check", id="python-release"), - pytest.param("release", "check", id="self-release"), - ], -) -def test_tagged_source_checks_frozen_hooks_before_prek(workflow_name: str, job_name: str) -> None: +def test_tagged_source_checks_frozen_hooks_before_prek() -> None: + root = Path(__file__).resolve().parents[2] + prepare = yaml.load((root / ".github/workflows/prepare-towncrier-release.yaml").read_text(), Loader=yaml.BaseLoader) + tagged = yaml.load((root / "check-tagged-source/action.yml").read_text(), Loader=yaml.BaseLoader) + + for steps in (prepare["jobs"]["assemble"]["steps"], tagged["runs"]["steps"]): + frozen = next(index for index, step in enumerate(steps) if step.get("uses") == "$/verify-frozen-hooks") + assert frozen < next(index for index, step in enumerate(steps) if "prek" in step.get("run", "")) + + +def test_every_hook_run_uses_the_root_config() -> None: root = Path(__file__).resolve().parents[2] - workflow = yaml.load((root / ".github/workflows" / f"{workflow_name}.yaml").read_text(), Loader=yaml.BaseLoader) - steps = workflow["jobs"][job_name]["steps"] + runs = [ + step.get("run", "") + for path in (*root.glob(".github/workflows/*.yaml"), *root.glob("*/action.yml")) + for document in [yaml.load(path.read_text(), Loader=yaml.BaseLoader)] + for steps in ( + [document["runs"]["steps"]] + if "runs" in document + else [job.get("steps", []) for job in document["jobs"].values()] + ) + for step in steps + ] + commands = [ + command + for run in runs + for command in re.findall(r"\bprek (?:run|install-hooks|update)\b[^\n]*", run.replace("\\\n", " ")) + ] + + assert commands + assert all("--config" in command for command in commands), commands + - assert next(index for index, step in enumerate(steps) if step.get("uses") == "$/verify-frozen-hooks") < next( - index for index, step in enumerate(steps) if "prek" in step.get("run", "") +def test_prepare_hook_install_uses_install_time_cutoff() -> None: + workflow = yaml.load( + (Path(__file__).resolve().parents[2] / ".github/workflows/prepare-towncrier-release.yaml").read_text(), + Loader=yaml.BaseLoader, ) + step = next(step for step in workflow["jobs"]["assemble"]["steps"] if step.get("name") == "Install prek hooks") + script = (Path(__file__).resolve().parents[2] / "tools/cooled_prek.sh").read_text() + assert {name: step["env"][name] for name in ("UV_EXCLUDE_NEWER", "UV_NO_CONFIG", "UV_NO_SOURCES")} == { + "UV_EXCLUDE_NEWER": "7 days", + "UV_NO_CONFIG": "1", + "UV_NO_SOURCES": "1", + } + assert [line for line in step["run"].splitlines() if line.startswith("unset ")] == [ + line for line in script.splitlines() if line.startswith("unset ") + ] -@pytest.mark.parametrize( - ("path", "step_name"), - [ - pytest.param(".github/workflows/check.yaml", "Check repository formatting", id="pr-format"), - pytest.param(".github/workflows/check.yaml", "Check inline shell", id="pr-inline-shell"), - pytest.param(".github/workflows/prepare-towncrier-release.yaml", "Install prek hooks", id="prepare-install"), - pytest.param(".github/workflows/release.yaml", "Check tagged source", id="self-release"), - pytest.param(".github/workflows/validate-python-release.yaml", "Run tagged source checks", id="python-release"), - pytest.param("prepare-towncrier/action.yml", "Format changelog", id="prepare-format"), - pytest.param("validate-release-patch/action.yml", "Run prek on release patch", id="patch-validation"), - ], -) -def test_hook_execution_uses_install_time_cutoff(path: str, step_name: str) -> None: - document = yaml.load((Path(__file__).resolve().parents[2] / path).read_text(), Loader=yaml.BaseLoader) - steps = ( - document["runs"]["steps"] - if path.endswith("action.yml") - else [step for job in document["jobs"].values() for step in job["steps"]] + +def test_cooled_prek_isolates_hook_installs(tmp_path: Path) -> None: + fake = tmp_path / "prek" + fake.write_text('#!/bin/sh\nprintf "%s\\n" "$@" >args\nenv >env\n') + fake.chmod(0o755) + leaked = dict.fromkeys(("UV_INDEX_URL", "UV_EXCLUDE_NEWER_PACKAGE", "UV_SYSTEM_CERTS", "SSL_CERT_FILE"), "leak") + + subprocess.run( + [str(Path(__file__).resolve().parents[2] / "tools/cooled_prek.sh"), "run", "--all-files"], + cwd=tmp_path, + env=os.environ | leaked | {"PATH": f"{tmp_path}:{os.environ['PATH']}", "UV_EXCLUDE_NEWER": "1 day"}, + check=True, ) - step = next(step for step in steps if step.get("name") == step_name) - assert step["env"]["UV_EXCLUDE_NEWER"] == "7 days" - assert step["env"]["UV_NO_CONFIG"] == "1" - assert step["env"]["UV_NO_SOURCES"] == "1" - unset_names = {name for line in step["run"].splitlines() if line.startswith("unset ") for name in line.split()[1:]} - assert { - "UV_CONFIG_FILE", - "UV_INDEX", - "UV_INDEX_URL", - "UV_DEFAULT_INDEX", - "UV_EXTRA_INDEX_URL", - "UV_FIND_LINKS", - "UV_EXCLUDE_NEWER_PACKAGE", - "UV_INSECURE_HOST", - "UV_SYSTEM_CERTS", - "SSL_CERT_FILE", - "SSL_CERT_DIR", - } <= unset_names + assert (tmp_path / "args").read_text().splitlines() == ["--config", ".pre-commit-config.yaml", "run", "--all-files"] + environment = dict(line.split("=", 1) for line in (tmp_path / "env").read_text().splitlines() if "=" in line) + assert {name: environment.get(name) for name in ("UV_EXCLUDE_NEWER", "UV_NO_CONFIG", "UV_NO_SOURCES")} == { + "UV_EXCLUDE_NEWER": "7 days", + "UV_NO_CONFIG": "1", + "UV_NO_SOURCES": "1", + } + assert not leaked.keys() & environment.keys() diff --git a/tests/release_workflow/test_graph.py b/tests/release_workflow/test_graph.py index 28ca01b..8182422 100644 --- a/tests/release_workflow/test_graph.py +++ b/tests/release_workflow/test_graph.py @@ -18,30 +18,9 @@ def test_prepare_workflow_calls_reusable_preparation() -> None: assert workflow["jobs"]["prepare"]["uses"] == "$/.github/workflows/prepare-towncrier-release.yaml" -@pytest.mark.parametrize( - ("workflow", "job", "step_name"), - [ - pytest.param("check.yaml", "check", "Check repository formatting", id="pull-request"), - pytest.param("release.yaml", "check", "Check tagged source", id="tagged-release"), - pytest.param("validate-python-release.yaml", "source-check", "Run tagged source checks", id="distribution"), - pytest.param("prepare-towncrier-release.yaml", "assemble", "Install prek hooks", id="prepare"), - ], -) -def test_release_hook_installation_has_cooldown(workflow: str, job: str, step_name: str) -> None: - document = yaml.safe_load((Path(__file__).resolve().parents[2] / ".github/workflows" / workflow).read_text()) - step = next(item for item in document["jobs"][job]["steps"] if item.get("name") == step_name) - assert step["env"]["UV_EXCLUDE_NEWER"] == "7 days" - - def test_tagged_release_checks_the_committed_lock() -> None: workflow = yaml.safe_load((Path(__file__).resolve().parents[2] / ".github/workflows/release.yaml").read_text()) - check = workflow["jobs"]["check"] - assert next(step for step in check["steps"] if step.get("uses") == "$/refresh-release-lock")["with"] == { - "mode": "check" - } assert "check" in workflow["jobs"]["publish"]["needs"] - assert check["permissions"] == {"actions": "read", "contents": "read", "pull-requests": "read"} - assert next(step for step in check["steps"] if "checkout" in step.get("uses", ""))["with"]["fetch-depth"] == 0 def test_prepare_jobs_transfer_the_uploaded_patch() -> None: diff --git a/tests/validate_python_release/test_tagged.py b/tests/validate_python_release/test_tagged.py index 7f790b5..4f39410 100644 --- a/tests/validate_python_release/test_tagged.py +++ b/tests/validate_python_release/test_tagged.py @@ -47,51 +47,24 @@ def build_constraints(build_repository: Path, tmp_path: Path) -> Path: return path -def test_validator_isolates_release_tools_from_build() -> None: - workflow = yaml.load( - (Path(__file__).resolve().parents[2] / ".github/workflows/validate-python-release.yaml").read_text(), - Loader=yaml.BaseLoader, - ) - assert set(workflow["on"]["workflow_call"]["inputs"]) == { - "source", - "artifact-prefix", - "keep-uv-lock", - "additional-lock", - "additional-lock-groups", - "additional-lock-selection", - "additional-matrix-python", - "additional-matrix-platforms", - "additional-matrix-implementations", - } - assert set(workflow["jobs"]) == {"source-check", "constraints", "build", "validate"} - assert workflow["jobs"]["build"]["needs"] == ["source-check", "constraints"] - lock_check = next( - step for step in workflow["jobs"]["source-check"]["steps"] if step.get("uses") == "$/refresh-release-lock" - ) - assert lock_check["with"]["mode"] == "check" - for option in workflow["on"]["workflow_call"]["inputs"]: - if option not in {"source", "artifact-prefix"}: - assert lock_check["with"][option] == f"${{{{ inputs.{option} }}}}" - assert not any(step.get("uses", "").startswith("$/install-") for step in workflow["jobs"]["build"]["steps"]) - assert workflow["jobs"]["validate"]["needs"] == "build" - assert not any(step.get("uses") == "$/install-locked-tools" for step in workflow["jobs"]["validate"]["steps"]) - validator = next( - step for step in workflow["jobs"]["validate"]["steps"] if step.get("name") == "Install distribution validators" +@pytest.mark.parametrize( + ("workflow_name", "job"), + [ + pytest.param("validate-python-release", "source-check", id="python-release"), + pytest.param("release", "check", id="self-release"), + ], +) +def test_tagged_source_has_history_and_read_access_for_hook_proof(workflow_name: str, job: str) -> None: + workflow = yaml.safe_load( + (Path(__file__).resolve().parents[2] / ".github/workflows" / f"{workflow_name}.yaml").read_text() ) - assert "twine>=7" in validator["run"] - assert "check-wheel-contents>=0.6.3" in validator["run"] - - -def test_tagged_source_has_history_and_read_access_for_hook_proof(release_workflow: dict[str, Any]) -> None: - source = release_workflow["jobs"]["source-check"] + source = workflow["jobs"][job] assert source["permissions"] == {"actions": "read", "contents": "read", "pull-requests": "read"} assert next(step for step in source["steps"] if "checkout" in step.get("uses", ""))["with"]["fetch-depth"] == 0 - assert [step.get("uses") for step in source["steps"]].index("$/refresh-release-lock") < [ - step.get("uses") for step in source["steps"] - ].index("$/verify-frozen-hooks") + assert "$/check-tagged-source" in [step.get("uses") for step in source["steps"]] -def test_tagged_source_rejects_stale_project_requirement(tmp_path: Path, release_workflow: dict[str, Any]) -> None: +def test_tagged_source_rejects_stale_project_requirement(tmp_path: Path) -> None: source = Path(__file__).resolve().parents[2] project = tmp_path / "pyproject.toml" project.write_text( @@ -117,10 +90,6 @@ def test_tagged_source_rejects_stale_project_requirement(tmp_path: Path, release assert selected.returncode == 0, selected.stderr assert freshness.returncode != 0 assert "prek>=999" in freshness.stderr - assert any( - step.get("uses") == "$/refresh-release-lock" and step.get("with", {}).get("mode") == "check" - for step in release_workflow["jobs"]["source-check"]["steps"] - ) def test_validator_builds_reproducible_distributions_and_checks_them( @@ -193,15 +162,10 @@ def test_validator_rejects_backend_hash_mismatch( ], ) @pytest.mark.parametrize( - ("workflow_name", "job"), - [ - pytest.param("validate-python-release", "source-check", id="source-check"), - pytest.param("validate-python-release", "build", id="build"), - pytest.param("release", "check", id="self-release"), - ], + "location", [pytest.param("check-tagged-source/action.yml", id="source-check"), pytest.param("build", id="build")] ) def test_validator_requires_exact_tag( - repository: Path, tag_kind: str, event_ref: str, accepted: bool, workflow_name: str, job: str + repository: Path, tag_kind: str, event_ref: str, accepted: bool, location: str ) -> None: if tag_kind == "branch": run(repository, "git", "branch", "0.2.0") @@ -213,10 +177,15 @@ def test_validator_requires_exact_tag( run(repository, "git", "tag", "-a", "-m", "Release", "0.2.0") else: run(repository, "git", "tag", "0.2.0") - workflow = yaml.safe_load( - (Path(__file__).resolve().parents[2] / ".github/workflows" / f"{workflow_name}.yaml").read_text() - ) - verify = next(step for step in workflow["jobs"][job]["steps"] if step.get("name") == "Verify the exact release tag") + root = Path(__file__).resolve().parents[2] + steps = ( + yaml.safe_load((root / location).read_text())["runs"]["steps"] + if location.endswith(".yml") + else yaml.safe_load((root / ".github/workflows/validate-python-release.yaml").read_text())["jobs"][location][ + "steps" + ] + ) + verify = next(step for step in steps if step.get("name") == "Verify the exact release tag") result = subprocess.run( ["bash", "-c", verify["run"]], cwd=repository, diff --git a/tools/cooled_prek.sh b/tools/cooled_prek.sh new file mode 100755 index 0000000..d7b9d9d --- /dev/null +++ b/tools/cooled_prek.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# Hook environments build from PyPI under the seven-day cutoff, ignoring the repository's uv settings. +set -euo pipefail +unset UV_CONFIG_FILE UV_INDEX UV_INDEX_URL UV_DEFAULT_INDEX UV_EXTRA_INDEX_URL +unset UV_FIND_LINKS UV_EXCLUDE_NEWER_PACKAGE +unset UV_INSECURE_HOST UV_SYSTEM_CERTS SSL_CERT_FILE SSL_CERT_DIR +export UV_EXCLUDE_NEWER="7 days" UV_NO_CONFIG=1 UV_NO_SOURCES=1 +exec prek --config .pre-commit-config.yaml "$@" diff --git a/validate-release-patch/action.yml b/validate-release-patch/action.yml index 65a3545..7978932 100644 --- a/validate-release-patch/action.yml +++ b/validate-release-patch/action.yml @@ -123,16 +123,8 @@ runs: - name: Run prek on release patch if: inputs.checks == 'true' shell: bash - env: - UV_EXCLUDE_NEWER: 7 days - UV_NO_CONFIG: "1" - UV_NO_SOURCES: "1" run: | - unset UV_CONFIG_FILE UV_INDEX UV_INDEX_URL UV_DEFAULT_INDEX UV_EXTRA_INDEX_URL - unset UV_FIND_LINKS UV_EXCLUDE_NEWER_PACKAGE - unset UV_INSECURE_HOST UV_SYSTEM_CERTS SSL_CERT_FILE SSL_CERT_DIR - set -euo pipefail - if ! prek run --config .pre-commit-config.yaml --all-files; then + if ! "$GITHUB_ACTION_PATH/../tools/cooled_prek.sh" run --all-files; then echo '::error::prek failed on the validated release patch' >&2 exit 1 fi From 5fd0697a8d99acdb0774ddfa8d1f116a3ac93108 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sat, 24 Oct 2026 23:38:01 -0700 Subject: [PATCH 32/44] refactor(release): share the local tool install --- .../workflows/prepare-towncrier-release.yaml | 36 ++--------- install-local-release-creator/action.yml | 21 +++++++ tests/release_workflow/test_graph.py | 62 +++++++++---------- 3 files changed, 58 insertions(+), 61 deletions(-) create mode 100644 install-local-release-creator/action.yml diff --git a/.github/workflows/prepare-towncrier-release.yaml b/.github/workflows/prepare-towncrier-release.yaml index 01e577a..3cd3030 100644 --- a/.github/workflows/prepare-towncrier-release.yaml +++ b/.github/workflows/prepare-towncrier-release.yaml @@ -112,22 +112,10 @@ jobs: unset UV_INSECURE_HOST UV_SYSTEM_CERTS SSL_CERT_FILE SSL_CERT_DIR prek install-hooks --config .pre-commit-config.yaml background: true - - name: Build local release tool wheel + - uses: $/install-local-release-creator if: inputs.local-tool-source - env: - BUILD_CONSTRAINTS: ${{ steps.tools.outputs.build-constraints }} - run: | - uv build --wheel --build-constraints "$BUILD_CONSTRAINTS" \ - --require-build-hashes --out-dir "$RUNNER_TEMP/local-tool-dist" . - - name: Install local release tool wheel - if: inputs.local-tool-source - run: uv pip install --python .venv/bin/python --no-deps "$RUNNER_TEMP/local-tool-dist/"*.whl - - name: Expose local release command - if: inputs.local-tool-source - run: | - mkdir -p "$RUNNER_TEMP/local-release-cli" - ln -s "$GITHUB_WORKSPACE/.venv/bin/release-creator" "$RUNNER_TEMP/local-release-cli/release-creator" - echo "$RUNNER_TEMP/local-release-cli" >>"$GITHUB_PATH" + with: + build-constraints: ${{ steps.tools.outputs.build-constraints }} - id: prepare name: Build changelog uses: $/prepare-towncrier @@ -170,22 +158,10 @@ jobs: groups: release-validate${{ inputs.local-tool-source && ' project-runtime' || '' }} venv: .venv local-tool-source: ${{ inputs.local-tool-source }} - - name: Build local release tool wheel + - uses: $/install-local-release-creator if: inputs.local-tool-source - env: - BUILD_CONSTRAINTS: ${{ steps.tools.outputs.build-constraints }} - run: | - uv build --wheel --build-constraints "$BUILD_CONSTRAINTS" \ - --require-build-hashes --out-dir "$RUNNER_TEMP/local-tool-dist" . - - name: Install local release tool wheel - if: inputs.local-tool-source - run: uv pip install --python .venv/bin/python --no-deps "$RUNNER_TEMP/local-tool-dist/"*.whl - - name: Expose local release command - if: inputs.local-tool-source - run: | - mkdir -p "$RUNNER_TEMP/local-release-cli" - ln -s "$GITHUB_WORKSPACE/.venv/bin/release-creator" "$RUNNER_TEMP/local-release-cli/release-creator" - echo "$RUNNER_TEMP/local-release-cli" >>"$GITHUB_PATH" + with: + build-constraints: ${{ steps.tools.outputs.build-constraints }} - id: config uses: $/read-towncrier-config - name: Verify selected release version diff --git a/install-local-release-creator/action.yml b/install-local-release-creator/action.yml new file mode 100644 index 0000000..8fc1059 --- /dev/null +++ b/install-local-release-creator/action.yml @@ -0,0 +1,21 @@ +name: Install local release-creator +description: Build release-creator from the checkout into .venv and put its command first on PATH +inputs: + build-constraints: + description: Hashed build constraints from install-locked-tools + required: true +runs: + using: composite + steps: + - name: Build and install the local release-creator wheel + shell: bash + env: + BUILD_CONSTRAINTS: ${{ inputs.build-constraints }} + run: | + set -euo pipefail + uv build --wheel --build-constraints "$BUILD_CONSTRAINTS" \ + --require-build-hashes --out-dir "$RUNNER_TEMP/local-tool-dist" . + uv pip install --python .venv/bin/python --no-deps "$RUNNER_TEMP/local-tool-dist/"*.whl + mkdir -p "$RUNNER_TEMP/local-release-cli" + ln -s "$GITHUB_WORKSPACE/.venv/bin/release-creator" "$RUNNER_TEMP/local-release-cli/release-creator" + echo "$RUNNER_TEMP/local-release-cli" >>"$GITHUB_PATH" diff --git a/tests/release_workflow/test_graph.py b/tests/release_workflow/test_graph.py index 8182422..0deb6da 100644 --- a/tests/release_workflow/test_graph.py +++ b/tests/release_workflow/test_graph.py @@ -106,56 +106,56 @@ def test_prepare_validation_calculates_version_independently( assert "assembled release version differs from 1.2.3" in result.stderr -@pytest.mark.parametrize("job", ["assemble", "validate"]) -def test_prepare_builds_local_tool_with_locked_backends(job: str) -> None: +@pytest.mark.parametrize(("job", "consumer"), [("assemble", "prepare"), ("validate", "config")]) +def test_prepare_installs_local_tool_before_using_it(job: str, consumer: str) -> None: workflow = yaml.safe_load( (Path(__file__).resolve().parents[2] / ".github/workflows/prepare-towncrier-release.yaml").read_text() ) steps = workflow["jobs"][job]["steps"] - builds = [step for step in steps if step.get("name") == "Build local release tool wheel"] - assert len(builds) == 1 - assert builds[0]["env"]["BUILD_CONSTRAINTS"] == "${{ steps.tools.outputs.build-constraints }}" - assert '--build-constraints "$BUILD_CONSTRAINTS"' in builds[0]["run"] - assert "--require-build-hashes" in builds[0]["run"] - assert any( - step.get("name") == "Install local release tool wheel" and '"$RUNNER_TEMP/local-tool-dist/"*.whl' in step["run"] - for step in steps - ) - - -def test_prepare_exposes_local_command_without_shadowing_python(tmp_path: Path) -> None: - workflow = yaml.safe_load( - (Path(__file__).resolve().parents[2] / ".github/workflows/prepare-towncrier-release.yaml").read_text() - ) - steps = workflow["jobs"]["assemble"]["steps"] - expose = next(step for step in steps if step.get("name") == "Expose local release command") - assert expose["if"] == "inputs.local-tool-source" - assert steps.index(expose) > next( - index for index, step in enumerate(steps) if step.get("name") == "Install local release tool wheel" - ) - assert steps.index(expose) < next(index for index, step in enumerate(steps) if step.get("id") == "prepare") + local = next(step for step in steps if step.get("uses") == "$/install-local-release-creator") + assert local["with"]["build-constraints"] == "${{ steps.tools.outputs.build-constraints }}" + assert steps.index(local) < next(index for index, step in enumerate(steps) if step.get("id") == consumer) + + +def test_local_release_creator_builds_with_locked_backends(tmp_path: Path) -> None: + step = yaml.safe_load( + (Path(__file__).resolve().parents[2] / "install-local-release-creator/action.yml").read_text() + )["runs"]["steps"][0] + fake_bin = tmp_path / "fake-bin" + fake_bin.mkdir() + (fake_bin / "uv").write_text( + '#!/bin/sh\necho "$@" >>"$RUNNER_TEMP/uv-calls"\n' + 'if [ "$1" = build ]; then mkdir -p "$RUNNER_TEMP/local-tool-dist" && touch "$RUNNER_TEMP/local-tool-dist/x.whl"; fi\n' + ) + (fake_bin / "uv").chmod(0o755) command = tmp_path / ".venv/bin/release-creator" command.parent.mkdir(parents=True) command.symlink_to(sys.executable) output = tmp_path / "github-path" result = subprocess.run( - ["bash", "-e", "-o", "pipefail", "-c", expose["run"]], + ["bash", "-e", "-o", "pipefail", "-c", step["run"]], cwd=tmp_path, - env={**os.environ, "GITHUB_WORKSPACE": str(tmp_path), "RUNNER_TEMP": str(tmp_path), "GITHUB_PATH": str(output)}, + env=os.environ + | { + "PATH": f"{fake_bin}:{os.environ['PATH']}", + "BUILD_CONSTRAINTS": "constraints.txt", + "GITHUB_WORKSPACE": str(tmp_path), + "RUNNER_TEMP": str(tmp_path), + "GITHUB_PATH": str(output), + }, capture_output=True, text=True, check=False, ) assert result.returncode == 0, result.stderr + build, install = (tmp_path / "uv-calls").read_text().splitlines() + assert "--build-constraints constraints.txt --require-build-hashes" in build + assert install.endswith(f"--no-deps {tmp_path}/local-tool-dist/x.whl") overlay = Path(output.read_text().strip()) + assert [path.name for path in overlay.iterdir()] == ["release-creator"] assert (overlay / "release-creator").resolve() == command.resolve() - assert not (overlay / "python").exists() - assert ( - subprocess.run([str(overlay / "release-creator"), "--version"], check=False, capture_output=True).returncode - == 0 - ) def test_prepare_installs_hooks_while_building_changelog() -> None: From 954c16558a05a6143974f9354f93a3d55120c305 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sat, 24 Oct 2026 23:39:39 -0700 Subject: [PATCH 33/44] build: drop the unused Nab policy table --- pyproject.toml | 35 ---------------------- tests/install_locked_tools/test_install.py | 10 ++----- 2 files changed, 2 insertions(+), 43 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b2100fe..70a3831 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,41 +40,6 @@ test = [ "ty", ] -[tool.nab] -mode = "universal" -uploaded-prior-to = "P7D" -decision-order = "stable" -base-group = "project-runtime" -default-groups = [] - -[tool.nab.matrix] -python = ">=3.15,<3.16" -platforms = [ "linux_x86_64", "linux_aarch64", "macos_x86_64", "macos_arm64", "windows_amd64", "windows_arm64" ] - -[tool.nab.packages.nab] -uploaded-prior-to = "2026-10-18T05:45:09Z" - -[tool.nab.packages.nab-index] -uploaded-prior-to = "2026-10-18T05:45:09Z" - -[tool.nab.packages.nab-markersets] -uploaded-prior-to = "2026-10-18T05:45:09Z" - -[tool.nab.packages.nab-project] -uploaded-prior-to = "2026-10-18T05:45:09Z" - -[tool.nab.packages.nab-provider] -uploaded-prior-to = "2026-10-18T05:45:09Z" - -[tool.nab.packages.nab-resolver] -uploaded-prior-to = "2026-10-18T05:45:09Z" - -[tool.nab.packages.release-creator] -uploaded-prior-to = "2026-10-24T09:08:40Z" - -[tool.nab.packages.uv] -uploaded-prior-to = "2026-10-22T01:32:34Z" - [tool.release-creator.locks.exclude-newer-package] nab = "2026-10-18T05:45:09Z" nab-index = "2026-10-18T05:45:09Z" diff --git a/tests/install_locked_tools/test_install.py b/tests/install_locked_tools/test_install.py index 0a0a74e..25afe66 100644 --- a/tests/install_locked_tools/test_install.py +++ b/tests/install_locked_tools/test_install.py @@ -808,14 +808,8 @@ def test_shared_lock_records_upload_times() -> None: def test_shared_lock_respects_seven_day_cutoff() -> None: source = Path(__file__).resolve().parents[2] - policy = tomllib.loads((source / "pyproject.toml").read_text())["tool"]["nab"] - floors = { - name: datetime.fromisoformat(settings["uploaded-prior-to"]) - for name, settings in policy.get("packages", {}).items() - if "uploaded-prior-to" in settings - } - for rule in policy.get("package-rules", []): - floors.update(dict.fromkeys(rule["match"], datetime.fromisoformat(rule["uploaded-prior-to"]))) + overrides = tomllib.loads((source / "pyproject.toml").read_text())["tool"]["release-creator"]["locks"] + floors = {name: datetime.fromisoformat(value) for name, value in overrides.get("exclude-newer-package", {}).items()} lock = tomllib.loads((source / "pylock.toml").read_text()) cutoff = datetime.now(UTC) - timedelta(days=7) packages = lock["packages"] From 6bfe4f64fef6c3097864e16e236a27ccd17f5985 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sat, 24 Oct 2026 23:43:40 -0700 Subject: [PATCH 34/44] test(marketplace): check the job run against None --- .../publish_jetbrains_marketplace/test_marketplace_upload.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/publish_jetbrains_marketplace/test_marketplace_upload.py b/tests/publish_jetbrains_marketplace/test_marketplace_upload.py index 45c2699..4e1f301 100644 --- a/tests/publish_jetbrains_marketplace/test_marketplace_upload.py +++ b/tests/publish_jetbrains_marketplace/test_marketplace_upload.py @@ -830,7 +830,9 @@ def publish_action( else: job_request.respond_with_json( { - "run_id": previous_deployment_job_run if prior and previous_deployment_job_run else run_id, + "run_id": previous_deployment_job_run + if prior and previous_deployment_job_run is not None + else run_id, "run_attempt": attempt, "steps": [ { From 95804dc91a1f800f9f92418ba30dc5d2f007cbb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sat, 24 Oct 2026 23:43:40 -0700 Subject: [PATCH 35/44] test: measure the test suite at full coverage --- pyproject.toml | 4 +- tests/conftest.py | 79 ++-------------------- tests/refresh_release_lock/test_cutoffs.py | 1 + tests/test_harness.py | 25 +++++++ 4 files changed, 34 insertions(+), 75 deletions(-) create mode 100644 tests/test_harness.py diff --git a/pyproject.toml b/pyproject.toml index 70a3831..a25099e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,7 +56,9 @@ line-length = 120 [tool.coverage.run] branch = true patch = [ "subprocess" ] -source_dirs = [ "tools" ] +source_dirs = [ "tests", "tools" ] +# The bootstrap starts coverage in subprocesses, so it runs before measurement begins. +omit = [ "*/coverage_bootstrap/*" ] [tool.coverage.report] fail_under = 100 diff --git a/tests/conftest.py b/tests/conftest.py index 0393145..198e4fe 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,15 +8,13 @@ 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, TypedDict, cast from urllib.parse import urlsplit -from urllib.request import urlopen import certifi +import coverage import pytest import trustme import yaml @@ -88,63 +86,6 @@ 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", "--no-cache", "--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 pypi_metadata(tmp_path_factory: pytest.TempPathFactory) -> Path: listings = _lock_metadata(Path(__file__).resolve().parents[1] / "pylock.toml") @@ -435,9 +376,6 @@ def _run_action( continue if used.startswith("actions/upload-artifact@"): continue - if not used.startswith("$/"): - msg = f"unsupported nested action: {used}" - raise ValueError(msg) nested_name = used.removeprefix("$/") nested_config = yaml.safe_load((source / nested_name / "action.yml").read_text()) nested_inputs = { @@ -467,8 +405,6 @@ def _run_action( if step_id := step.get("id"): step_outputs[step_id] = outputs continue - if "run" not in step: - continue script = action_scripts / f"{name}-{index}.sh" command = [*github_bash, str(script)] output_path = Path(environment["GITHUB_OUTPUT"]) @@ -567,13 +503,10 @@ def _safe_environment() -> dict[str, str]: ) if name in os.environ } - if "GW_PYPI_METADATA_FILE" in environment: - environment["PYTHONPATH"] = str(Path(__file__).parent / "coverage_bootstrap") - if "COVERAGE_PROCESS_CONFIG" in environment or "COVERAGE_PROCESS_START" in environment: - import coverage - - environment["GW_COVERAGE_SITE_PACKAGES"] = str(Path(coverage.__file__).parent.parent) - return environment + return environment | { + "PYTHONPATH": str(Path(__file__).parent / "coverage_bootstrap"), + "GW_COVERAGE_SITE_PACKAGES": str(Path(coverage.__file__).parent.parent), + } def _inputs( @@ -651,8 +584,6 @@ def _expression( return environment["GITHUB_REPOSITORY"] if expression == "github.base_ref": return environment.get("BASE_REF", "") - if expression == "github.event.pull_request.number": - return environment.get("GITHUB_EVENT_PULL_REQUEST_NUMBER", "") if expression == "github.token": return environment.get("GH_TOKEN", environment.get("GH_ENTERPRISE_TOKEN", "")) if expression == "github.event.pull_request.user.type": diff --git a/tests/refresh_release_lock/test_cutoffs.py b/tests/refresh_release_lock/test_cutoffs.py index 1f66d36..ee3c43f 100644 --- a/tests/refresh_release_lock/test_cutoffs.py +++ b/tests/refresh_release_lock/test_cutoffs.py @@ -168,6 +168,7 @@ def test_uv_resolver_cutoff_requires_review(cutoff: str, accepted: bool) -> None ("project_text", "accepted"), [ pytest.param("[tool.release-creator.locks]\n", True, id="release-creator-only"), + pytest.param('[tool.nab]\nuploaded-prior-to = "P7D"\n', True, id="secondary-nab-only"), pytest.param("[tool.other]\n", False, id="no-policy"), ], ) diff --git a/tests/test_harness.py b/tests/test_harness.py new file mode 100644 index 0000000..8380938 --- /dev/null +++ b/tests/test_harness.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path + +import pytest +from conftest import _condition, _step_value, run + + +def test_run_reports_failed_command(tmp_path: Path) -> None: + with pytest.raises(AssertionError, match="false failed with exit code 1"): + run(tmp_path, "false") + + +@pytest.mark.parametrize( + ("helper", "value", "message"), + [ + pytest.param(_condition, "contains(inputs.x, 'y')", "unsupported GitHub step condition", id="condition"), + pytest.param(_step_value, "${{ inputs.x", "unterminated GitHub expression", id="unterminated"), + pytest.param(_step_value, "${{ github.actor }}", "unsupported GitHub step environment", id="environment"), + ], +) +def test_harness_rejects_unsupported_workflow_syntax(helper: Callable[..., object], value: str, message: str) -> None: + with pytest.raises(ValueError, match=message): + helper(value, {}, {}, {}) From f244f5342f8001b490869d8ade53059f0b6ccfbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sat, 24 Oct 2026 23:44:41 -0700 Subject: [PATCH 36/44] test: autospec network and platform mocks --- .../test_expose_commands.py | 9 +++---- tests/install_locked_tools/test_metadata.py | 10 ++++---- tests/verify_pypi_files/test_verify.py | 24 ++++++++++--------- 3 files changed, 24 insertions(+), 19 deletions(-) diff --git a/tests/install_locked_tools/test_expose_commands.py b/tests/install_locked_tools/test_expose_commands.py index 3054245..e093d4e 100644 --- a/tests/install_locked_tools/test_expose_commands.py +++ b/tests/install_locked_tools/test_expose_commands.py @@ -17,11 +17,12 @@ def test_expose_locked_commands_copies_windows_executable(tmp_path: Path, mocker scripts.mkdir(parents=True) (scripts / "release.exe").write_bytes(b"tool") github_path = tmp_path / "github-path" - platform = mocker.patch.object(expose_locked_commands, "os") - platform.name = "nt" - platform.environ = {"RUNNER_TEMP": str(tmp_path), "GITHUB_PATH": str(github_path)} - platform.X_OK = os.X_OK + platform = mocker.create_autospec( + os, environ={"RUNNER_TEMP": str(tmp_path), "GITHUB_PATH": str(github_path)}, X_OK=os.X_OK + ) platform.access.return_value = True + platform.name = "nt" # Mock reserves the name keyword, so the platform name is set afterwards. + mocker.patch.object(expose_locked_commands, "os", platform) mocker.patch.object(sys, "argv", ["expose_locked_commands", str(environment)]) expose_locked_commands.main() diff --git a/tests/install_locked_tools/test_metadata.py b/tests/install_locked_tools/test_metadata.py index d0acd42..de94c81 100644 --- a/tests/install_locked_tools/test_metadata.py +++ b/tests/install_locked_tools/test_metadata.py @@ -82,7 +82,9 @@ def pypi_listing() -> _PyPIListing: def test_lock_metadata_matches_pypi( lock_case: tuple[Path, Path], pypi_listing: _PyPIListing, mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch ) -> None: - request = mocker.patch("urllib.request.urlopen", return_value=BytesIO(json.dumps(pypi_listing).encode())) + request = mocker.patch( + "urllib.request.urlopen", autospec=True, return_value=BytesIO(json.dumps(pypi_listing).encode()) + ) context = mocker.patch.object(ssl, "create_default_context", autospec=True) _run_check(lock_case, monkeypatch, "--verify-pypi", "--project", str(lock_case[1])) @@ -184,7 +186,7 @@ def test_lock_metadata_stops_after_five_network_errors( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], ) -> None: - request = mocker.patch("urllib.request.urlopen", side_effect=URLError("handshake timed out")) + request = mocker.patch("urllib.request.urlopen", autospec=True, side_effect=URLError("handshake timed out")) mocker.patch("time.sleep") with pytest.raises(SystemExit, match="2"): @@ -290,7 +292,7 @@ def test_lock_metadata_rejects_invalid_listing( pypi_listing["meta"]["api-version"] = "2.0" else: pypi_listing["name"] = "other" - mocker.patch("urllib.request.urlopen", return_value=BytesIO(json.dumps(pypi_listing).encode())) + mocker.patch("urllib.request.urlopen", autospec=True, return_value=BytesIO(json.dumps(pypi_listing).encode())) with pytest.raises(SystemExit, match="2"): _run_check(lock_case, monkeypatch, "--verify-pypi", "--project", str(lock_case[1])) @@ -313,7 +315,7 @@ def test_lock_metadata_rejects_non_object_listing( capsys: pytest.CaptureFixture[str], listing: object, ) -> None: - mocker.patch("urllib.request.urlopen", return_value=BytesIO(json.dumps(listing).encode())) + mocker.patch("urllib.request.urlopen", autospec=True, return_value=BytesIO(json.dumps(listing).encode())) with pytest.raises(SystemExit, match="2"): _run_check(lock_case, monkeypatch, "--verify-pypi", "--project", str(lock_case[1])) diff --git a/tests/verify_pypi_files/test_verify.py b/tests/verify_pypi_files/test_verify.py index 3ff7aae..808868f 100644 --- a/tests/verify_pypi_files/test_verify.py +++ b/tests/verify_pypi_files/test_verify.py @@ -68,7 +68,7 @@ def test_verify_files_matches_complete_release(distribution: Path, mocker: Mocke def test_verify_files_uses_pinned_trust_store(distribution: Path, mocker: MockerFixture) -> None: - request = mocker.patch.object(_MODULE, "urlopen", return_value=_body(_listing(distribution))) + request = mocker.patch.object(_MODULE, "urlopen", autospec=True, return_value=_body(_listing(distribution))) context = mocker.patch.object(ssl, "create_default_context", autospec=True) verify_files("fixture", "1.0", distribution) @@ -116,7 +116,7 @@ def test_main_reports_invalid_directory( def test_script_entrypoint_prints_distribution_paths( distribution: Path, mocker: MockerFixture, capsys: pytest.CaptureFixture[str] ) -> None: - mocker.patch("urllib.request.urlopen", return_value=_body(_listing(distribution))) + mocker.patch("urllib.request.urlopen", autospec=True, return_value=_body(_listing(distribution))) mocker.patch("sys.argv", ["verify_pypi_files", "fixture", "1.0", str(distribution)]) runpy.run_path(str(Path(__file__).resolve().parents[2] / "tools/verify_pypi_files.py"), run_name="__main__") @@ -147,7 +147,7 @@ def test_verify_files_rejects_directory_entry(distribution: Path) -> None: def test_verify_files_ignores_other_release_wheel(distribution: Path, mocker: MockerFixture) -> None: files = _listing(distribution) files.append({"filename": "fixture-0.9-py3-none-any.whl", "hashes": {"sha256": "0" * 64}, "yanked": False}) - mocker.patch.object(_MODULE, "urlopen", return_value=_body(files)) + mocker.patch.object(_MODULE, "urlopen", autospec=True, return_value=_body(files)) assert verify_files("fixture", "1.0", distribution) == [ distribution / "fixture-1.0-py3-none-any.whl", @@ -175,7 +175,7 @@ def test_verify_files_propagates_http_error(distribution: Path, mocker: MockerFi def test_verify_files_propagates_exhausted_connection_error(distribution: Path, mocker: MockerFixture) -> None: - mocker.patch.object(_MODULE, "urlopen", side_effect=URLError("offline")) + mocker.patch.object(_MODULE, "urlopen", autospec=True, side_effect=URLError("offline")) with pytest.raises(URLError, match="offline"): verify_files("fixture", "1.0", distribution) @@ -200,7 +200,7 @@ def test_verify_files_reports_missing_release_after_404(distribution: Path, mock ], ) def test_verify_files_rejects_invalid_listing(distribution: Path, mocker: MockerFixture, payload: _Json) -> None: - mocker.patch.object(_MODULE, "urlopen", return_value=io.BytesIO(json.dumps(payload).encode())) + mocker.patch.object(_MODULE, "urlopen", autospec=True, return_value=io.BytesIO(json.dumps(payload).encode())) with pytest.raises( (TypeError, ValueError), match="invalid Simple API response|unsupported PyPI Simple API version" @@ -231,7 +231,7 @@ def test_verify_files_rejects_invalid_file_entry(distribution: Path, mocker: Moc def test_verify_files_ignores_legacy_egg_from_older_release(distribution: Path, mocker: MockerFixture) -> None: files = _listing(distribution) files.append({"filename": "fixture-0.8.1-py2.4.egg", "hashes": {"sha256": "0" * 64}, "yanked": False}) - mocker.patch.object(_MODULE, "urlopen", return_value=_body(files)) + mocker.patch.object(_MODULE, "urlopen", autospec=True, return_value=_body(files)) assert verify_files("fixture", "1.0", distribution) == [ distribution / "fixture-1.0-py3-none-any.whl", @@ -254,7 +254,7 @@ def test_verify_files_ignores_legacy_files_from_other_versions( path.rename(path.with_name(path.name.replace("1.0", "0.9"))) files = _listing(distribution) files.append({"filename": filename, "hashes": {"sha256": "0" * 64}, "yanked": False}) - mocker.patch.object(_MODULE, "urlopen", return_value=_body(files)) + mocker.patch.object(_MODULE, "urlopen", autospec=True, return_value=_body(files)) assert verify_files("fixture", "0.9", distribution) == [ distribution / "fixture-0.9-py3-none-any.whl", @@ -343,7 +343,9 @@ def test_verify_files_retries_transient_listing_failure( failure: OSError | IncompleteRead | json.JSONDecodeError, waiting_clock: None, ) -> None: - response = mocker.patch.object(_MODULE, "urlopen", side_effect=[failure, _body(_listing(distribution))]) + response = mocker.patch.object( + _MODULE, "urlopen", autospec=True, side_effect=[failure, _body(_listing(distribution))] + ) assert verify_files("fixture", "1.0", distribution) == [ distribution / "fixture-1.0-py3-none-any.whl", @@ -364,7 +366,7 @@ def test_verify_files_retries_response_read_failure( ) -> None: response = mocker.create_autospec(io.BytesIO, instance=True) response.__enter__.return_value.read.side_effect = failure - mocker.patch.object(_MODULE, "urlopen", side_effect=[response, _body(_listing(distribution))]) + mocker.patch.object(_MODULE, "urlopen", autospec=True, side_effect=[response, _body(_listing(distribution))]) assert verify_files("fixture", "1.0", distribution) == [ distribution / "fixture-1.0-py3-none-any.whl", @@ -389,7 +391,7 @@ def test_verify_files_retries_truncated_json_response( 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.object(_MODULE, "urlopen", autospec=True, side_effect=IncompleteRead(b"partial", 10)) mocker.patch.object(_MODULE.time, "monotonic", side_effect=[0, 121]) mocker.patch("sys.argv", ["verify_pypi_files", "fixture", "1.0", str(distribution)]) @@ -433,7 +435,7 @@ def _serve(mocker: MockerFixture, distribution: Path, *, change: str = "") -> No files.append(files[0].copy()) elif change == "invalid-name": files.append({"filename": "fixture-1.0.invalid", "hashes": {"sha256": "0" * 64}, "yanked": False}) - mocker.patch.object(_MODULE, "urlopen", return_value=_body(files)) + mocker.patch.object(_MODULE, "urlopen", autospec=True, return_value=_body(files)) def _listing(distribution: Path) -> list[_ListingFile]: From 6eb97601bf8112072277cd077f8ac5ac0b310361 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sat, 24 Oct 2026 23:46:46 -0700 Subject: [PATCH 37/44] test: drop tests that restate workflow YAML --- .../test_cooldown.py | 30 ----- tests/release_workflow/test_graph.py | 110 ------------------ tests/upgrade_release_tools/test_upgrade.py | 19 --- tests/validate_news/test_news.py | 12 -- tests/validate_release_patch/test_validate.py | 7 -- 5 files changed, 178 deletions(-) diff --git a/tests/check_hook_update_cooldown/test_cooldown.py b/tests/check_hook_update_cooldown/test_cooldown.py index ea7df86..3534fe6 100644 --- a/tests/check_hook_update_cooldown/test_cooldown.py +++ b/tests/check_hook_update_cooldown/test_cooldown.py @@ -9,40 +9,10 @@ from typing import Literal import pytest -import yaml from conftest import action from pytest_httpserver import HTTPServer -def test_action_uses_scoped_job_token() -> None: - action = yaml.safe_load((Path(__file__).resolve().parents[2] / "check-hook-update-cooldown/action.yml").read_text()) - assert [step["env"]["GH_TOKEN"] for step in action["runs"]["steps"] if "GH_TOKEN" in step.get("env", {})] == [ - "${{ github.token }}", - "${{ github.token }}", - "${{ github.token }}", - "${{ github.token }}", - ] - assert action["runs"]["steps"][0]["env"]["RELEASE_APP_CLIENT_ID"] == "${{ inputs.release-app-client-id }}" - workflow = yaml.safe_load((Path(__file__).resolve().parents[2] / ".github/workflows/check.yaml").read_text()) - caller = workflow["jobs"]["hook-cooldown"]["steps"][0] - assert caller["with"]["release-app-client-id"] == "${{ vars.RELEASE_APP_CLIENT_ID }}" - - -def test_action_checks_app_hook_closure_from_pull_request_head() -> None: - steps = yaml.safe_load((Path(__file__).resolve().parents[2] / "check-hook-update-cooldown/action.yml").read_text())[ - "runs" - ]["steps"] - source = next(step for step in steps if step.get("name") == "Read the base and proposed hook configurations") - closure = next(step for step in steps if step.get("name") == "Verify managed hook dependency closure") - - assert '"repos/$head_repository/contents/.pre-commit-config.yaml?ref=$HEAD_SHA"' in source["run"] - assert closure["if"] == "steps.identity.outputs.changed == 'true'" - assert 'cp "$RUNNER_TEMP/proposed-hooks.yaml" "$RUNNER_TEMP/hook-closure/.pre-commit-config.yaml"' in closure["run"] - assert "release-creator hooks" in closure["run"] - assert '--check --minimum-days "$MINIMUM_DAYS"' in closure["run"] - assert closure["env"]["MINIMUM_DAYS"] == "${{ inputs.minimum-days }}" - - @pytest.mark.parametrize( ("author", "revision", "frozen_base", "accepted"), [ diff --git a/tests/release_workflow/test_graph.py b/tests/release_workflow/test_graph.py index 0deb6da..14e1419 100644 --- a/tests/release_workflow/test_graph.py +++ b/tests/release_workflow/test_graph.py @@ -10,54 +10,6 @@ import yaml -def test_prepare_workflow_calls_reusable_preparation() -> None: - workflow = yaml.safe_load( - (Path(__file__).resolve().parents[2] / ".github/workflows/prepare-release.yaml").read_text() - ) - assert list(workflow["jobs"]) == ["prepare"] - assert workflow["jobs"]["prepare"]["uses"] == "$/.github/workflows/prepare-towncrier-release.yaml" - - -def test_tagged_release_checks_the_committed_lock() -> None: - workflow = yaml.safe_load((Path(__file__).resolve().parents[2] / ".github/workflows/release.yaml").read_text()) - assert "check" in workflow["jobs"]["publish"]["needs"] - - -def test_prepare_jobs_transfer_the_uploaded_patch() -> None: - workflow = yaml.safe_load( - (Path(__file__).resolve().parents[2] / ".github/workflows/prepare-towncrier-release.yaml").read_text() - ) - jobs = workflow["jobs"] - assert list(jobs) == ["assemble", "validate", "push"] - assert jobs["assemble"]["outputs"]["artifact-id"] == "${{ steps.patch.outputs.artifact-id }}" - assert ( - next(step for step in jobs["assemble"]["steps"] if step.get("id") == "patch")["with"]["name"] == "release-patch" - ) - for job in ("validate", "push"): - assert ( - next(step for step in jobs[job]["steps"] if "download-artifact" in step.get("uses", ""))["with"][ - "artifact-ids" - ] - == "${{ needs.assemble.outputs.artifact-id }}" - ) - - -def test_prepare_validates_uploaded_patch_before_writer() -> None: - workflow = yaml.safe_load( - (Path(__file__).resolve().parents[2] / ".github/workflows/prepare-towncrier-release.yaml").read_text() - ) - jobs = workflow["jobs"] - assert jobs["validate"]["permissions"] == {"contents": "read"} - assert jobs["validate"]["needs"] == "assemble" - assert jobs["push"]["needs"] == ["assemble", "validate"] - assert not any(step.get("uses") == "$/validate-release-patch" for step in jobs["assemble"]["steps"]) - assert any(step.get("uses") == "$/validate-release-patch" for step in jobs["validate"]["steps"]) - assert ( - next(step for step in jobs["push"]["steps"] if step.get("uses") == "$/validate-release-patch")["with"]["checks"] - == "false" - ) - - @pytest.mark.parametrize( ("version", "prefix", "accepted"), [ @@ -106,17 +58,6 @@ def test_prepare_validation_calculates_version_independently( assert "assembled release version differs from 1.2.3" in result.stderr -@pytest.mark.parametrize(("job", "consumer"), [("assemble", "prepare"), ("validate", "config")]) -def test_prepare_installs_local_tool_before_using_it(job: str, consumer: str) -> None: - workflow = yaml.safe_load( - (Path(__file__).resolve().parents[2] / ".github/workflows/prepare-towncrier-release.yaml").read_text() - ) - steps = workflow["jobs"][job]["steps"] - local = next(step for step in steps if step.get("uses") == "$/install-local-release-creator") - assert local["with"]["build-constraints"] == "${{ steps.tools.outputs.build-constraints }}" - assert steps.index(local) < next(index for index, step in enumerate(steps) if step.get("id") == consumer) - - def test_local_release_creator_builds_with_locked_backends(tmp_path: Path) -> None: step = yaml.safe_load( (Path(__file__).resolve().parents[2] / "install-local-release-creator/action.yml").read_text() @@ -158,27 +99,6 @@ def test_local_release_creator_builds_with_locked_backends(tmp_path: Path) -> No assert (overlay / "release-creator").resolve() == command.resolve() -def test_prepare_installs_hooks_while_building_changelog() -> None: - workflow = yaml.safe_load( - (Path(__file__).resolve().parents[2] / ".github/workflows/prepare-towncrier-release.yaml").read_text() - ) - steps = workflow["jobs"]["assemble"]["steps"] - hooks = next(step for step in steps if step.get("id") == "hooks") - build = next(step for step in steps if step.get("id") == "prepare") - wait = next(step for step in steps if step.get("wait") == "hooks") - finalize = next(step for step in steps if step.get("name") == "Finalize release patch") - assert hooks["background"] is True - assert hooks["run"].splitlines()[-1] == "prek install-hooks --config .pre-commit-config.yaml" - assert [build["with"]["phase"], finalize["with"]["phase"]] == ["build", "finalize"] - assert steps.index(hooks) < steps.index(build) < steps.index(wait) < steps.index(finalize) - - -def test_changelog_checkout_fetches_the_base_branch() -> None: - workflow = yaml.safe_load((Path(__file__).resolve().parents[2] / ".github/workflows/news.yaml").read_text()) - checkout = next(step for step in workflow["jobs"]["changelog"]["steps"] if "checkout" in step.get("uses", "")) - assert checkout["with"]["fetch-depth"] == 0 - - def test_release_writer_has_only_notes_and_reconciler() -> None: workflow = yaml.safe_load((Path(__file__).resolve().parents[2] / ".github/workflows/release.yaml").read_text()) publish = workflow["jobs"]["publish"] @@ -192,36 +112,6 @@ def test_release_writer_has_only_notes_and_reconciler() -> None: assert publish["steps"][0]["with"]["artifact-ids"] == "${{ needs.notes.outputs.artifact-id }}" -def test_release_notes_run_without_write_access() -> None: - workflow = yaml.safe_load((Path(__file__).resolve().parents[2] / ".github/workflows/release.yaml").read_text()) - notes = workflow["jobs"]["notes"] - assert notes["permissions"] == {"contents": "read"} - assert notes["steps"][0]["with"]["fetch-depth"] == 1 - assert "$/render-release-notes" in [step.get("uses") for step in notes["steps"]] - assert notes["outputs"]["artifact-id"] == "${{ steps.artifact.outputs.artifact-id }}" - assert ( - next(step for step in notes["steps"] if "download-artifact" in step.get("uses", ""))["with"]["artifact-ids"] - == "${{ needs.contributors.outputs.artifact-id }}" - ) - - -def test_release_contributors_job_has_no_checkout() -> None: - workflow = yaml.safe_load((Path(__file__).resolve().parents[2] / ".github/workflows/release.yaml").read_text()) - contributors = workflow["jobs"]["contributors"] - assert contributors["permissions"] == {"contents": "write"} - assert not any("checkout" in step.get("uses", "") for step in contributors["steps"]) - assert not any("setup-uv" in step.get("uses", "") for step in contributors["steps"]) - - -def test_release_contributors_job_uploads_generated_notes() -> None: - workflow = yaml.safe_load((Path(__file__).resolve().parents[2] / ".github/workflows/release.yaml").read_text()) - contributors = workflow["jobs"]["contributors"] - assert contributors["steps"][0]["name"] == "Fetch contributor metadata" - assert "releases/generate-notes" in contributors["steps"][0]["run"] - assert contributors["steps"][1]["with"]["path"] == "${{ runner.temp }}/generated-notes.md" - assert contributors["outputs"]["artifact-id"] == "${{ steps.artifact.outputs.artifact-id }}" - - def test_shfmt_hook_rejects_unformatted_inline_shell(tmp_path: Path) -> None: subprocess.run(["git", "init", "-q", str(tmp_path)], check=True) script = tmp_path / "inline-shell-check.sh" diff --git a/tests/upgrade_release_tools/test_upgrade.py b/tests/upgrade_release_tools/test_upgrade.py index 78eeabd..3fe25f4 100644 --- a/tests/upgrade_release_tools/test_upgrade.py +++ b/tests/upgrade_release_tools/test_upgrade.py @@ -237,25 +237,6 @@ def test_upgrade_combines_lock_and_hook_patches(lock_repository: Path, tmp_path: ] -@pytest.mark.parametrize("kind", ["lock", "hooks"], ids=["lock", "hooks"]) -def test_upgrade_downloads_the_generated_patch(kind: str) -> None: - workflow = yaml.safe_load( - (Path(__file__).resolve().parents[2] / ".github/workflows/upgrade-release-tools.yaml").read_text() - ) - generator = workflow["jobs"][f"generate-{kind}"] - download = next( - step - for step in workflow["jobs"]["publish"]["steps"] - if step.get("with", {}).get("path") == f"${{{{ runner.temp }}}}/release-tools-{kind}" - ) - - assert generator["outputs"]["artifact-id"] == "${{ steps.upload.outputs.artifact-id }}" - assert next(step for step in generator["steps"] if step.get("id") == "upload")["with"]["name"] == ( - f"release-tools-{kind}" - ) - assert download["with"]["artifact-ids"] == f"${{{{ needs.generate-{kind}.outputs.artifact-id }}}}" - - def test_upgrade_preserves_requested_pull_request_body( lock_repository: Path, tmp_path: Path, publish_scripts: Path ) -> None: diff --git a/tests/validate_news/test_news.py b/tests/validate_news/test_news.py index 315635e..64d3b3e 100644 --- a/tests/validate_news/test_news.py +++ b/tests/validate_news/test_news.py @@ -5,7 +5,6 @@ from pathlib import Path import pytest -import yaml from conftest import action, run @@ -130,17 +129,6 @@ def test_validate_news_requires_pr_base(repository: Path, tmp_path: Path, action assert "news validation needs a PR base" in result.stderr -def test_validate_news_forwards_secondary_lock_inputs() -> None: - config = yaml.safe_load((Path(__file__).resolve().parents[2] / "validate-news/action.yml").read_text()) - refresh = next(step for step in config["runs"]["steps"] if step.get("uses") == "$/refresh-release-lock") - secondary = [name for name in config["inputs"] if name.startswith("additional-")] - - assert secondary - assert all(refresh["with"][name] == "${{ inputs." + name + " }}" for name in secondary) - assert refresh["with"]["mode"] == "check" - assert refresh["with"]["keep-uv-lock"] == "${{ inputs.keep-uv-lock }}" - - @pytest.mark.parametrize( ("additional_lock", "keep_uv_lock", "changed_path", "changed"), [ diff --git a/tests/validate_release_patch/test_validate.py b/tests/validate_release_patch/test_validate.py index 5bba7f3..76844a3 100644 --- a/tests/validate_release_patch/test_validate.py +++ b/tests/validate_release_patch/test_validate.py @@ -4,7 +4,6 @@ from textwrap import dedent import pytest -import yaml from conftest import action, run @@ -28,12 +27,6 @@ def test_validate_applies_patch_in_fresh_worktree(repository: Path, tmp_path: Pa assert run(repository, "git", "diff", "--cached", "--name-only").stdout == source_staged -def test_validate_uses_current_checkout() -> None: - config = yaml.safe_load((Path(__file__).resolve().parents[2] / "validate-release-patch/action.yml").read_text()) - assert "working-directory" not in config.get("inputs", {}) - assert all("working-directory" not in step for step in config["runs"]["steps"]) - - def track_rst_changelog(repository: Path) -> None: (repository / "CHANGELOG.rst").write_text("Changelog\n=========\n") project = repository / "pyproject.toml" From 769c2b9179064a38013c69aa875276b635b88467 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sat, 24 Oct 2026 23:47:07 -0700 Subject: [PATCH 38/44] test(release): check the tag list given to tags --- tests/release_workflow/test_graph.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/tests/release_workflow/test_graph.py b/tests/release_workflow/test_graph.py index 14e1419..0c4a8d6 100644 --- a/tests/release_workflow/test_graph.py +++ b/tests/release_workflow/test_graph.py @@ -32,10 +32,28 @@ def test_prepare_validation_calculates_version_independently( if prefix: expected_arguments.extend(["--prefix", prefix]) command.write_text( - f"#!{sys.executable}\nimport sys\nassert sys.argv[1:] == {expected_arguments!r}\nprint({f'{prefix}1.2.3'!r})\n" + f"#!{sys.executable}\nimport sys\nassert sys.argv[1:] == {expected_arguments!r}\n" + f"assert sys.stdin.read() == {f'{prefix}1.2.2' + chr(10)!r}\nprint({f'{prefix}1.2.3'!r})\n" ) command.chmod(0o755) subprocess.run(["git", "init", "-q", str(tmp_path)], check=True) + subprocess.run( + [ + "git", + "-c", + "user.name=Test", + "-c", + "user.email=test@example.org", + "commit", + "-q", + "--allow-empty", + "-m", + "x", + ], + cwd=tmp_path, + check=True, + ) + subprocess.run(["git", "tag", f"{prefix}1.2.2"], cwd=tmp_path, check=True) result = subprocess.run( ["bash", "-e", "-o", "pipefail", "-c", verify["run"]], From 753692271bc0a4bf5967b52dff80f2e44a784599 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sat, 24 Oct 2026 23:47:43 -0700 Subject: [PATCH 39/44] docs(changelog): note the 3.0.1 caller permissions --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 40c2f6c..ceb8a0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,14 @@ match the fork repository, branch, commit, and workflow, and occur after the PR opened ([#15](https://github.com/tox-dev/github-workflows/pull/15)) by [@gaborbernat](https://github.com/gaborbernat). + + +### Breaking changes + +- The tagged `source-check` job of `validate-python-release` requests `actions: read` and `pull-requests: read` and + checks out full history for the hook age check. A caller whose `build` job grants only `contents: read` fails at + startup; grant all three. This entry was added after the release, which shipped without it. + ### Security From a01fd0f2fffc3fb17c30e6b71a8ad0871ed94b72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sat, 24 Oct 2026 23:47:43 -0700 Subject: [PATCH 40/44] docs: describe the round 13 release changes --- README.md | 56 ++++++++++++++++++++++++++++++++----------------------- 1 file changed, 33 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 83233b7..a817ebd 100644 --- a/README.md +++ b/README.md @@ -96,12 +96,12 @@ Call `upgrade-release-tools.yaml` from a scheduled or manual workflow. Its read- `pylock.toml` and the frozen pre-commit hooks in parallel. `release-creator locks` exports runtime dependencies, all extras, and all dependency groups with a seven-day package cutoff. Reviewed exceptions live in `[tool.release-creator.locks.exclude-newer-package]`. Both lock checks and weekly refresh install uv from the shared -`release-resolver` group, so a setup-uv default change cannot alter the generated lock. The shared weekly updater -refreshes this direct pin and the lock together; consumer locks use their pinned shared resolver. The hook updater -applies the same cooldown to hook revisions and their `additional_dependencies`. Each job uploads a patch. The lock -patch may change `pylock.toml`, an explicitly configured secondary Nab lock, and `pyproject.toml` for expired reviewed -cutoffs. The hook patch may change only `.pre-commit-config.yaml`. The writer checks each patch before applying it and -verifies cutoff removals before minting the App token. It then opens or updates one dependency PR from +`release-resolver` group, so a setup-uv default change cannot alter the generated lock. In github-workflows, the weekly +updater also refreshes this direct pin and the lock together; consumer locks use their pinned shared resolver. The hook +updater applies the same cooldown to hook revisions and their `additional_dependencies`. Each job uploads a patch. The +lock patch may change `pylock.toml`, an explicitly configured secondary Nab lock, and `pyproject.toml` for expired +reviewed cutoffs. The hook patch may change only `.pre-commit-config.yaml`. The writer checks each patch before applying +it and verifies cutoff removals before minting the App token. It then opens or updates one dependency PR from `automation/update-release-tools` to `main`. A lock or hook update runs the normal PR checks before merge. The writer uses a repository-only release App with Contents and Pull requests write access. Install the App on this @@ -127,10 +127,10 @@ jobs: RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} ``` -`lock-groups` remains an input for callers with an older Nab primary lock; the full project exporter ignores it and -records every dependency group and extra. Tests install test, type, and documentation tools afresh to detect upstream -dependency changes. The updater also refreshes a caller's secondary Nab build-matrix lock when `additional-lock` and its -matrix inputs are set. +`pylock.toml` must come from `release-creator locks`, which records every dependency group and extra; a Nab primary lock +fails with a request to relock. Tests install test, type, and documentation tools afresh to detect upstream dependency +changes. The updater also refreshes a caller's secondary Nab build-matrix lock when `additional-lock` and its matrix +inputs are set. The optional `commit-message`, `pull-request-title`, and `pull-request-body` inputs retain existing PR wording. The workflow updates a committed `uv.lock` only when `keep-uv-lock: true`; pass that input to `validate-news` too. Both @@ -199,20 +199,25 @@ workflow run at least that many days earlier. The default is seven days. A force revision's age or give a new revision the old one's age. GitHub can omit pull request links from workflow runs. For those runs, the Action observes the exact head repository, branch, commit, and workflow after the PR opened; two PRs from the same head can share that code observation. Human PRs may add hooks or change their options under the same -revision and dependency checks. A human PR may add the first managed pins when the base configuration has none; the -complete closure check still applies. Once the base has managed pins, only the release App may change them. Other bot -PRs may change revisions only. On `automation/update-release-tools`, the Action verifies the bot against -`RELEASE_APP_CLIENT_ID` and permits exact, cooled PyPI dependency pins generated by the release App. Every external hook -revision must be a full lowercase commit SHA. A revision with new Python dependencies stays blocked until the App -updates its managed pins. Tagged source jobs check frozen revisions and the complete Python dependency closure before -running `prek`. +revision and dependency checks. A human PR may add or change managed pins when each new pin is an exact PyPI release at +least `minimum-days` old; the complete closure check still applies. Other bot PRs may change revisions only. On +`automation/update-release-tools`, the Action verifies the bot against `RELEASE_APP_CLIENT_ID` and permits exact, cooled +PyPI dependency pins generated by the release App. Every external hook revision must be a full lowercase commit SHA. +Only Python hooks may declare `additional_dependencies`, because only those get exact, cooled pins. A revision with new +Python dependencies stays blocked until its managed pins are updated. Tagged source jobs check frozen revisions and the +complete Python dependency closure before running `prek`. + +Every shared `prek` command passes `--config .pre-commit-config.yaml`. Without it, prek prefers a root `prek.toml` and +also runs nested configurations, which no guard checks. The cooldown check and `verify-frozen-hooks` therefore reject a +`prek.toml` anywhere, a `.pre-commit-config.yml`, and a `.pre-commit-config.yaml` below the root. On a tag, `verify-frozen-hooks` also checks when each new external `(repository, commit)` pair was first present in GitHub. It compares the tag with the previous published release in the same tag stream and on the first-parent release line. A new pair must come from a merged PR, and either its merge or a PR run containing the exact pair must be at least seven days old. The first release has no baseline: every external pair needs that proof. Directly pushed hook changes -cannot acquire it. This read-only Action has no outputs; it fails before `prek` runs. The tagged source job needs -`actions: read`, `contents: read`, `pull-requests: read`, and a checkout with `fetch-depth: 0`. +cannot acquire it. The proof runs before `release-creator hooks --check` builds any hook repository. This read-only +Action has no outputs; it fails before `prek` runs. The tagged source job needs `actions: read`, `contents: read`, +`pull-requests: read`, and a checkout with `fetch-depth: 0`. For a release dispatched from `main` with a tag input, pass that tag to the Action. It checks that the checkout is the tag's commit; a dispatch on `main` with the wrong checkout fails. @@ -224,9 +229,12 @@ tag's commit; a dispatch on `main` with the wrong checkout fails. ``` The shared prepare and tagged validator run `refresh-release-lock` in `check` mode before release checks or publishing. -Pass `keep-uv-lock: true` when the repository commits a native `uv.lock`. For a secondary Nab lock, pass its path, -selected groups, selection type, and matrix inputs to both workflows. A stale primary, native, or secondary lock stops -the release before artifacts are built. +`check-tagged-source` bundles the tagged checks for a custom tag publisher: it requires `GITHUB_REF` to be the tag of +the checkout on `main`, checks the locks, installs `release-check`, runs `verify-frozen-hooks`, and then runs +`prek run --all-files` with the seven-day cutoff. It takes `keep-uv-lock` and the secondary lock inputs below, and needs +the same permissions and full-history checkout as `verify-frozen-hooks`. Pass `keep-uv-lock: true` when the repository +commits a native `uv.lock`. For a secondary Nab lock, pass its path, selected groups, selection type, and matrix inputs +to both workflows. A stale primary, native, or secondary lock stops the release before artifacts are built. For example, a tagged source job that also publishes from a PGO lock checks both files before installing build tools: @@ -328,7 +336,9 @@ build: pull-requests: read ``` -The caller needs `release-check` for prek and a `build` group for backend constraints. `source` defaults to `.`. Use +The `build` job must grant `actions: read` and `pull-requests: read` as well as `contents: read`. The tagged hook age +check has needed them since 3.0.1, and GitHub refuses to start the workflow when the caller grants less. The caller +needs `release-check` for prek and a `build` group for backend constraints. `source` defaults to `.`. Use `artifact-prefix` when one tag produces more than one distribution path; each call then writes a distinct `python-package-distributions` artifact. A caller pinned to a workflow commit runs that commit's local Actions. From c573c63e25f088f36b15ab6f43c5dd1f1572c969 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sat, 24 Oct 2026 23:48:22 -0700 Subject: [PATCH 41/44] refactor(release): expose the local tool like locked ones --- install-local-release-creator/action.yml | 5 ++--- tests/release_workflow/test_graph.py | 4 +++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/install-local-release-creator/action.yml b/install-local-release-creator/action.yml index 8fc1059..08a557a 100644 --- a/install-local-release-creator/action.yml +++ b/install-local-release-creator/action.yml @@ -16,6 +16,5 @@ runs: uv build --wheel --build-constraints "$BUILD_CONSTRAINTS" \ --require-build-hashes --out-dir "$RUNNER_TEMP/local-tool-dist" . uv pip install --python .venv/bin/python --no-deps "$RUNNER_TEMP/local-tool-dist/"*.whl - mkdir -p "$RUNNER_TEMP/local-release-cli" - ln -s "$GITHUB_WORKSPACE/.venv/bin/release-creator" "$RUNNER_TEMP/local-release-cli/release-creator" - echo "$RUNNER_TEMP/local-release-cli" >>"$GITHUB_PATH" + uv run --no-project --python "$RUNNER_TEMP/lock-reader" python \ + "$GITHUB_ACTION_PATH/../tools/expose_locked_commands.py" .venv diff --git a/tests/release_workflow/test_graph.py b/tests/release_workflow/test_graph.py index 0c4a8d6..e2fa411 100644 --- a/tests/release_workflow/test_graph.py +++ b/tests/release_workflow/test_graph.py @@ -85,6 +85,7 @@ def test_local_release_creator_builds_with_locked_backends(tmp_path: Path) -> No (fake_bin / "uv").write_text( '#!/bin/sh\necho "$@" >>"$RUNNER_TEMP/uv-calls"\n' 'if [ "$1" = build ]; then mkdir -p "$RUNNER_TEMP/local-tool-dist" && touch "$RUNNER_TEMP/local-tool-dist/x.whl"; fi\n' + f'if [ "$1" = run ]; then shift 4; shift; exec {sys.executable} "$@"; fi\n' ) (fake_bin / "uv").chmod(0o755) command = tmp_path / ".venv/bin/release-creator" @@ -102,6 +103,7 @@ def test_local_release_creator_builds_with_locked_backends(tmp_path: Path) -> No "GITHUB_WORKSPACE": str(tmp_path), "RUNNER_TEMP": str(tmp_path), "GITHUB_PATH": str(output), + "GITHUB_ACTION_PATH": str(Path(__file__).resolve().parents[2] / "install-local-release-creator"), }, capture_output=True, text=True, @@ -109,7 +111,7 @@ def test_local_release_creator_builds_with_locked_backends(tmp_path: Path) -> No ) assert result.returncode == 0, result.stderr - build, install = (tmp_path / "uv-calls").read_text().splitlines() + build, install, _ = (tmp_path / "uv-calls").read_text().splitlines() assert "--build-constraints constraints.txt --require-build-hashes" in build assert install.endswith(f"--no-deps {tmp_path}/local-tool-dist/x.whl") overlay = Path(output.read_text().strip()) From 7304ef8006c16f0f1de138b83c9eea7ccc3f8eee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sat, 24 Oct 2026 23:48:43 -0700 Subject: [PATCH 42/44] refactor(lock)!: rename the cutoff patch check --- .github/workflows/upgrade-release-tools.yaml | 2 +- changelog.d/+verify-cutoff-patch.breaking.md | 2 ++ {verify-nab-cutoff-patch => verify-cutoff-patch}/action.yml | 4 ++-- 3 files changed, 5 insertions(+), 3 deletions(-) create mode 100644 changelog.d/+verify-cutoff-patch.breaking.md rename {verify-nab-cutoff-patch => verify-cutoff-patch}/action.yml (55%) diff --git a/.github/workflows/upgrade-release-tools.yaml b/.github/workflows/upgrade-release-tools.yaml index 81d90c7..114db8c 100644 --- a/.github/workflows/upgrade-release-tools.yaml +++ b/.github/workflows/upgrade-release-tools.yaml @@ -209,7 +209,7 @@ jobs: exit 1 fi done <"$RUNNER_TEMP/dependency-modes" - - uses: $/verify-nab-cutoff-patch + - uses: $/verify-cutoff-patch - name: Commit refreshed tools env: COMMIT_MESSAGE: ${{ inputs.commit-message }} diff --git a/changelog.d/+verify-cutoff-patch.breaking.md b/changelog.d/+verify-cutoff-patch.breaking.md new file mode 100644 index 0000000..aa5448e --- /dev/null +++ b/changelog.d/+verify-cutoff-patch.breaking.md @@ -0,0 +1,2 @@ +Rename the `verify-nab-cutoff-patch` Action to `verify-cutoff-patch`, since it checks release-creator cutoffs and the +shared resolver pin as well as Nab cutoffs. diff --git a/verify-nab-cutoff-patch/action.yml b/verify-cutoff-patch/action.yml similarity index 55% rename from verify-nab-cutoff-patch/action.yml rename to verify-cutoff-patch/action.yml index e4d4fbf..9074fc0 100644 --- a/verify-nab-cutoff-patch/action.yml +++ b/verify-cutoff-patch/action.yml @@ -1,5 +1,5 @@ -name: Verify Nab cutoff patch -description: Reject pyproject changes other than expired Nab package cutoffs +name: Verify cutoff patch +description: Reject pyproject changes other than expired package cutoffs and the shared resolver pin runs: using: composite steps: From 585d1e3cb30ca44ef0aee9cec62bd9100b8fd061 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sat, 24 Oct 2026 23:51:02 -0700 Subject: [PATCH 43/44] refactor: tighten round 13 code and tests --- tests/conftest.py | 9 ++--- tests/test_conftest.py | 11 ++++++ tests/test_harness.py | 25 ------------ tests/validate_news/test_lock_groups.py | 9 +++-- .../test_frozen_hook_action.py | 12 ++++-- tests/verify_pypi_files/test_verify.py | 18 ++++----- tools/lock_groups.py | 16 ++++---- tools/verify_cutoff_patch.py | 38 +++++++++---------- 8 files changed, 62 insertions(+), 76 deletions(-) create mode 100644 tests/test_conftest.py delete mode 100644 tests/test_harness.py diff --git a/tests/conftest.py b/tests/conftest.py index 198e4fe..a9c82c5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -478,8 +478,7 @@ def _condition( left, right = expression.split(operator, 1) equal = _expression(left, environment, inputs, step_outputs) == right.strip("'") return equal if operator == " == " else not equal - msg = f"unsupported GitHub step condition: {expression}" - raise ValueError(msg) + raise ValueError(f"unsupported GitHub step condition: {expression}") # pragma: no cover - new syntax only def _safe_environment() -> dict[str, str]: @@ -533,8 +532,7 @@ def _step_value( while (start := remainder.find("${{")) != -1: end = remainder.find("}}", start + 3) if end == -1: - msg = f"unterminated GitHub expression: {value}" - raise ValueError(msg) + raise ValueError(f"unterminated GitHub expression: {value}") # pragma: no cover - new syntax only parts.append(remainder[:start]) parts.append(_expression(remainder[start + 3 : end].strip(), environment, inputs, step_outputs)) remainder = remainder[end + 2 :] @@ -590,8 +588,7 @@ def _expression( return "Bot" if environment.get("BOT_AUTHOR") == "true" else "User" if expression == "contains(github.event.pull_request.labels.*.name, 'skip news')": return environment.get("SKIP_NEWS", "false") - msg = f"unsupported GitHub step environment: {expression}" - raise ValueError(msg) + raise ValueError(f"unsupported GitHub step environment: {expression}") # pragma: no cover - new syntax only def _lock_metadata(lock: Path) -> dict[str, list[_PyPIFile]]: diff --git a/tests/test_conftest.py b/tests/test_conftest.py new file mode 100644 index 0000000..0e0dea2 --- /dev/null +++ b/tests/test_conftest.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest +from conftest import run + + +def test_run_reports_failed_command(tmp_path: Path) -> None: + with pytest.raises(AssertionError, match="false failed with exit code 1"): + run(tmp_path, "false") diff --git a/tests/test_harness.py b/tests/test_harness.py deleted file mode 100644 index 8380938..0000000 --- a/tests/test_harness.py +++ /dev/null @@ -1,25 +0,0 @@ -from __future__ import annotations - -from collections.abc import Callable -from pathlib import Path - -import pytest -from conftest import _condition, _step_value, run - - -def test_run_reports_failed_command(tmp_path: Path) -> None: - with pytest.raises(AssertionError, match="false failed with exit code 1"): - run(tmp_path, "false") - - -@pytest.mark.parametrize( - ("helper", "value", "message"), - [ - pytest.param(_condition, "contains(inputs.x, 'y')", "unsupported GitHub step condition", id="condition"), - pytest.param(_step_value, "${{ inputs.x", "unterminated GitHub expression", id="unterminated"), - pytest.param(_step_value, "${{ github.actor }}", "unsupported GitHub step environment", id="environment"), - ], -) -def test_harness_rejects_unsupported_workflow_syntax(helper: Callable[..., object], value: str, message: str) -> None: - with pytest.raises(ValueError, match=message): - helper(value, {}, {}, {}) diff --git a/tests/validate_news/test_lock_groups.py b/tests/validate_news/test_lock_groups.py index 713eacd..3e19182 100644 --- a/tests/validate_news/test_lock_groups.py +++ b/tests/validate_news/test_lock_groups.py @@ -4,17 +4,18 @@ import subprocess import sys from pathlib import Path +from typing import Final import pytest from tools.lock_groups import validate_workspace_sources -TOOLS = Path(__file__).resolve().parents[2] / "tools" +_TOOLS: Final = Path(__file__).resolve().parents[2] / "tools" def _producer(lock: Path) -> subprocess.CompletedProcess[str]: return subprocess.run( - [sys.executable, str(TOOLS / "lock_groups.py"), str(lock)], capture_output=True, text=True, check=False + [sys.executable, str(_TOOLS / "lock_groups.py"), str(lock)], capture_output=True, text=True, check=False ) @@ -45,7 +46,7 @@ def test_lock_groups_rejects_other_producer(tmp_path: Path, content: str, messag def test_lock_groups_import_has_no_cli_side_effect(capsys: pytest.CaptureFixture[str]) -> None: - runpy.run_path(str(TOOLS / "lock_groups.py"), run_name="lock_groups") + runpy.run_path(str(_TOOLS / "lock_groups.py"), run_name="lock_groups") assert capsys.readouterr() == ("", "") @@ -115,7 +116,7 @@ def test_lock_check_rejects_unapproved_workspace_sources(tmp_path: Path, source: result = subprocess.run( [ sys.executable, - str(TOOLS / "check_lock_groups.py"), + str(_TOOLS / "check_lock_groups.py"), str(lock), "release-news", "--project", diff --git a/tests/verify_frozen_hooks/test_frozen_hook_action.py b/tests/verify_frozen_hooks/test_frozen_hook_action.py index 0d79830..dd52974 100644 --- a/tests/verify_frozen_hooks/test_frozen_hook_action.py +++ b/tests/verify_frozen_hooks/test_frozen_hook_action.py @@ -1,11 +1,15 @@ from __future__ import annotations from pathlib import Path +from typing import Final import pytest from conftest import action, run -LOCAL_HOOK = "repos:\n - repo: local\n hooks:\n - id: noop\n name: noop\n entry: 'true'\n language: system\n" +_LOCAL_HOOK: Final = ( + "repos:\n - repo: local\n hooks:\n - id: noop\n name: noop\n entry: 'true'\n" + " language: system\n" +) def _repository(tmp_path: Path, config: str, *extra: str) -> Path: @@ -15,14 +19,14 @@ def _repository(tmp_path: Path, config: str, *extra: str) -> Path: (repository / ".pre-commit-config.yaml").write_text(config) for path in extra: (repository / path).parent.mkdir(parents=True, exist_ok=True) - (repository / path).write_text(LOCAL_HOOK) + (repository / path).write_text(_LOCAL_HOOK) run(repository, "git", "add", ".") run(repository, "git", "-c", "user.name=Test", "-c", "user.email=test@example.org", "commit", "-qm", "Hooks") return repository def test_verify_frozen_hooks_accepts_root_configuration(tmp_path: Path, action_scripts: Path) -> None: - repository = _repository(tmp_path, LOCAL_HOOK) + repository = _repository(tmp_path, _LOCAL_HOOK) result = action(repository, tmp_path, action_scripts, "verify-frozen-hooks", version="") @@ -39,7 +43,7 @@ def test_verify_frozen_hooks_accepts_root_configuration(tmp_path: Path, action_s ], ) def test_verify_frozen_hooks_rejects_other_configuration(tmp_path: Path, action_scripts: Path, path: str) -> None: - repository = _repository(tmp_path, LOCAL_HOOK, path) + repository = _repository(tmp_path, _LOCAL_HOOK, path) result = action(repository, tmp_path, action_scripts, "verify-frozen-hooks", version="") diff --git a/tests/verify_pypi_files/test_verify.py b/tests/verify_pypi_files/test_verify.py index 808868f..dc06b58 100644 --- a/tests/verify_pypi_files/test_verify.py +++ b/tests/verify_pypi_files/test_verify.py @@ -303,9 +303,8 @@ def test_verify_files_rejects_bad_local_release(distribution: Path, change: str, verify_files("fixture", "1.0", distribution) -def test_verify_files_waits_for_incomplete_listing( - distribution: Path, mocker: MockerFixture, waiting_clock: None -) -> None: +@pytest.mark.usefixtures("waiting_clock") +def test_verify_files_waits_for_incomplete_listing(distribution: Path, mocker: MockerFixture) -> None: files = _listing(distribution) response = mocker.patch.object( _MODULE, @@ -337,11 +336,11 @@ def test_verify_files_waits_for_incomplete_listing( pytest.param(json.JSONDecodeError("truncated", "{", 1), id="truncated-json"), ], ) +@pytest.mark.usefixtures("waiting_clock") def test_verify_files_retries_transient_listing_failure( distribution: Path, mocker: MockerFixture, failure: OSError | IncompleteRead | json.JSONDecodeError, - waiting_clock: None, ) -> None: response = mocker.patch.object( _MODULE, "urlopen", autospec=True, side_effect=[failure, _body(_listing(distribution))] @@ -361,8 +360,9 @@ def test_verify_files_retries_transient_listing_failure( pytest.param(IncompleteRead(b"partial", 10), id="incomplete-read"), ], ) +@pytest.mark.usefixtures("waiting_clock") def test_verify_files_retries_response_read_failure( - distribution: Path, mocker: MockerFixture, failure: TimeoutError | IncompleteRead, waiting_clock: None + distribution: Path, mocker: MockerFixture, failure: TimeoutError | IncompleteRead ) -> None: response = mocker.create_autospec(io.BytesIO, instance=True) response.__enter__.return_value.read.side_effect = failure @@ -374,9 +374,8 @@ def test_verify_files_retries_response_read_failure( ] -def test_verify_files_retries_truncated_json_response( - distribution: Path, mocker: MockerFixture, waiting_clock: None -) -> None: +@pytest.mark.usefixtures("waiting_clock") +def test_verify_files_retries_truncated_json_response(distribution: Path, mocker: MockerFixture) -> None: response = mocker.patch.object( _MODULE, "urlopen", side_effect=[io.BytesIO(b'{"meta":'), _body(_listing(distribution))] ) @@ -402,7 +401,8 @@ def test_main_reports_exhausted_incomplete_read( assert "IncompleteRead" in capsys.readouterr().err -def test_verify_files_backs_off_between_retries(distribution: Path, mocker: MockerFixture, waiting_clock: None) -> None: +@pytest.mark.usefixtures("waiting_clock") +def test_verify_files_backs_off_between_retries(distribution: Path, mocker: MockerFixture) -> None: mocker.patch.object( _MODULE, "urlopen", diff --git a/tools/lock_groups.py b/tools/lock_groups.py index 378fdf7..20c97fa 100644 --- a/tools/lock_groups.py +++ b/tools/lock_groups.py @@ -21,6 +21,14 @@ def main() -> None: parser.error(f"{args.lock} must come from release-creator locks; relock it") +def lock_provider(tool: Mapping[str, object] | None) -> str: + if "release-creator" in (tool or {}): + return "release-creator" + if "nab" in (tool or {}): + return "nab" + raise ValueError("lock has no recognized producer") + + def validate_workspace_sources(project: Path) -> None: with project.open("rb") as stream: root = tomllib.load(stream) @@ -58,14 +66,6 @@ def _normalized_name(name: str) -> str: return "-".join(filter(None, name.casefold().replace("_", "-").replace(".", "-").split("-"))) -def lock_provider(tool: Mapping[str, object] | None) -> str: - if "release-creator" in (tool or {}): - return "release-creator" - if "nab" in (tool or {}): - return "nab" - raise ValueError("lock has no recognized producer") - - if __name__ == "__main__": main() diff --git a/tools/verify_cutoff_patch.py b/tools/verify_cutoff_patch.py index 6a3799e..5242515 100644 --- a/tools/verify_cutoff_patch.py +++ b/tools/verify_cutoff_patch.py @@ -45,8 +45,24 @@ def main() -> None: removed_names = {name for name in names if name not in after.get("exclude-newer-package", {})} if removed_names: remove_release_creator_cutoffs(before, removed_names) - if os.environ.get("GITHUB_REPOSITORY") == "tox-dev/github-workflows": - _accept_resolver_pin(original, actual, args.project.with_name("pylock.toml")) + resolvers = [project.get("dependency-groups", {}).get("release-resolver") for project in (original, actual)] + if ( + os.environ.get("GITHUB_REPOSITORY") == "tox-dev/github-workflows" + and resolvers[0] != resolvers[1] + and all( + isinstance(pins, list) and len(pins) == 1 and re.fullmatch(r"uv==[0-9.]+", str(pins[0])) + for pins in resolvers + ) + ): + version = resolvers[1][0].removeprefix("uv==") + if not any( + package.get("name") == "uv" + and package.get("version") == version + and '"release-resolver" in dependency_groups' in package.get("marker", "") + for package in tomllib.loads(args.project.with_name("pylock.toml").read_text())["packages"] + ): + raise ValueError(f"pylock.toml does not lock the updated resolver uv {version}") + original["dependency-groups"]["release-resolver"] = resolvers[1] if original != actual: producer = "package" if "release-creator" in original["tool"] else "Nab" parser.error(f"pyproject.toml has changes beyond expired {producer} cutoffs") @@ -54,24 +70,6 @@ def main() -> None: parser.error(str(error)) -def _accept_resolver_pin(original: dict, actual: dict, lock: Path) -> None: - before = original.get("dependency-groups", {}).get("release-resolver") - after = actual.get("dependency-groups", {}).get("release-resolver") - if before == after or not all( - isinstance(pins, list) and len(pins) == 1 and isinstance(pins[0], str) and re.fullmatch(r"uv==[0-9.]+", pins[0]) - for pins in (before, after) - ): - return - version = after[0].removeprefix("uv==") - resolver = '"release-resolver" in dependency_groups' - if not any( - package.get("name") == "uv" and package.get("version") == version and resolver in package.get("marker", "") - for package in tomllib.loads(lock.read_text())["packages"] - ): - raise ValueError(f"pylock.toml does not lock the updated resolver uv {version}") - original["dependency-groups"]["release-resolver"] = after - - if __name__ == "__main__": main() From 522211ea37019a5c435a5146da7e6762f41725a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sat, 24 Oct 2026 23:52:30 -0700 Subject: [PATCH 44/44] refactor(tools): inline single-use constants --- tools/check_hook_config.py | 25 +++++++++++++------------ tools/nab_cutoffs.py | 5 ++--- tools/verify_pypi_files.py | 7 ------- 3 files changed, 15 insertions(+), 22 deletions(-) diff --git a/tools/check_hook_config.py b/tools/check_hook_config.py index 5844bc0..65d2cc6 100644 --- a/tools/check_hook_config.py +++ b/tools/check_hook_config.py @@ -11,8 +11,7 @@ from copy import deepcopy from datetime import UTC, datetime, timedelta from pathlib import Path -from re import Pattern -from typing import Final, cast +from typing import cast from urllib.parse import quote, urlsplit from urllib.request import Request, urlopen @@ -23,15 +22,6 @@ from yaml import MappingNode, ScalarNode, SequenceNode, YAMLError, compose, safe_load from yaml.error import Mark -_MANAGED_MARKER: Final[Pattern[str]] = re.compile( - r""" - ^ \s* ,? \s* - (?P [#] \s+ release-creator \s+ managed ) - (?: \s | $ ) - """, - re.VERBOSE, -) - def main() -> None: parser = argparse.ArgumentParser(description=__doc__) @@ -134,7 +124,18 @@ def _dependency_groups(source: str) -> Iterator[list[tuple[str, bool]]]: def _managed_marker(source: str, item: ScalarNode) -> bool: suffix = source[cast("Mark", item.end_mark).index :].split("\n", 1)[0] - return _MANAGED_MARKER.match(suffix) is not None + return ( + re.match( + r""" + ^ \s* ,? \s* + [#] \s+ release-creator \s+ managed + (?: \s | $ ) + """, + suffix, + re.VERBOSE, + ) + is not None + ) def _reject_duplicate_keys(source: str) -> None: diff --git a/tools/nab_cutoffs.py b/tools/nab_cutoffs.py index d08c27f..3da0280 100644 --- a/tools/nab_cutoffs.py +++ b/tools/nab_cutoffs.py @@ -13,7 +13,6 @@ """, re.VERBOSE, ) -_MINIMUM_DAYS: Final = 7 _REVIEWED_VERSIONS: Final = { **{ @@ -43,7 +42,7 @@ def validate_recent_cutoffs(project: str, threshold: datetime) -> None: 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: + if int(match["days"]) < 7: raise ValueError("global Nab cutoff must be a duration of at least P7D") candidates = [ (name, settings["uploaded-prior-to"]) @@ -61,7 +60,7 @@ def validate_recent_cutoffs(project: str, threshold: datetime) -> None: 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: + if int(match["days"]) < 7: raise ValueError(f"Nab cutoff for {name} weakens the seven-day policy: {value}") continue timestamp = _timestamp(name, value) diff --git a/tools/verify_pypi_files.py b/tools/verify_pypi_files.py index a76cf68..56ee3fb 100644 --- a/tools/verify_pypi_files.py +++ b/tools/verify_pypi_files.py @@ -44,13 +44,6 @@ def main() -> None: def verify_files(project: str, version: str, dist: Path) -> list[Path]: - """Check the local files against PyPI. - - :param project: Published project name. - :param version: Published release version. - :param dist: Directory containing every expected distribution. - :returns: Local distribution paths for attestation verification. - """ if not dist.is_dir(): raise ValueError(f"distribution directory does not exist: {dist}") name = canonicalize_name(project, validate=True)