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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ jobs:
env:
EXCLUDE_NEWER: 7 days
run: |
tools/install_live_tools.sh .release-tools 'prek>=0.2' 'yq>=4.4.5'
tools/install_live_tools.py .release-tools 'prek>=0.2' 'yq>=4.4.5'
uv run --no-project --python .release-tools python tools/expose_locked_commands.py .release-tools
- name: 🧹 Check inline shell
run: |
Expand All @@ -60,8 +60,8 @@ jobs:
done
scripts=("$script_dir"/*.sh)
echo "Checking ${#scripts[@]} run blocks from the workflows and Actions with shellcheck and shfmt"
tools/cooled_prek.sh run shellcheck --files "$script_dir"/*.sh
tools/cooled_prek.sh run shfmt --files "$script_dir"/*.sh
tools/cooled_prek.py run shellcheck --files "$script_dir"/*.sh
tools/cooled_prek.py run shfmt --files "$script_dir"/*.sh
test:
name: 🧪 Test actions
runs-on: ubuntu-26.04
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/news.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ jobs:
# Temporary: release-creator 4.3.2 is younger than seven days. Delete after 2026-11-06.
EXCLUDE_NEWER_PACKAGE: release-creator=2026-10-30T15:13:19Z
run: |
tools/install_live_tools.sh "$RUNNER_TEMP/release-tools" 'release-creator>=4.1' 'towncrier>=26.9'
tools/install_live_tools.py "$RUNNER_TEMP/release-tools" 'release-creator>=4.1' 'towncrier>=26.9'
uv run --no-project --python "$RUNNER_TEMP/release-tools" python tools/expose_locked_commands.py "$RUNNER_TEMP/release-tools"
- name: 🔍 Validate news fragments
uses: $/validate-news
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ jobs:
# Temporary: release-creator 4.3.2 is younger than seven days. Delete after 2026-11-06.
EXCLUDE_NEWER_PACKAGE: release-creator=2026-10-30T15:13:19Z
run: |
tools/install_live_tools.sh .venv 'release-creator>=4.3'
tools/install_live_tools.py .venv 'release-creator>=4.3'
uv run --no-project --python .venv python tools/expose_locked_commands.py .venv
- name: 📝 Render release notes
uses: $/render-release-notes
Expand Down
2 changes: 2 additions & 0 deletions changelog.d/+cooled-prek-fstring-pins.bugfix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
`cooled-prek` admits exact PyPI pins built from static f-strings in hook `setup.py` files, so a Dependabot bump of
`typos` can install while the pinned release is younger than the seven-day cutoff.
2 changes: 1 addition & 1 deletion cooled-prek/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,4 @@ runs:
run: |
set -euo pipefail
read -ra arguments <<<"$ARGS"
"$GITHUB_ACTION_PATH/../tools/cooled_prek.sh" ${arguments[@]+"${arguments[@]}"}
"$GITHUB_ACTION_PATH/../tools/cooled_prek.py" ${arguments[@]+"${arguments[@]}"}
2 changes: 1 addition & 1 deletion install-locked-tools/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ runs:
EXCLUDE_NEWER: ${{ inputs.exclude-newer }}
EXCLUDE_NEWER_PACKAGE: ${{ inputs.exclude-newer-package }}
run: >-
"$GITHUB_ACTION_PATH/../tools/install_live_tools.sh" "$RUNNER_TEMP/lock-reader" --only-binary :all: 'certifi>=2026.7.22' 'packaging>=26.3'
uv run --no-project --python "$RUNNER_TEMP/lock-reader" python "$GITHUB_ACTION_PATH/../tools/install_live_tools.py" "$RUNNER_TEMP/lock-reader" --only-binary :all: 'certifi>=2026.7.22' 'packaging>=26.3'
- id: selection
name: ⚙️ Select locked groups and build constraints
shell: bash
Expand Down
2 changes: 1 addition & 1 deletion prepare-towncrier/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ runs:
*) formatter=mdformat ;;
esac
echo "Changelog formatter: $formatter ($FORMAT changelog $CHANGELOG)"
prek=$GITHUB_ACTION_PATH/../tools/cooled_prek.sh
prek=$GITHUB_ACTION_PATH/../tools/cooled_prek.py
if ! "$prek" run "$formatter" --files "$CHANGELOG"; then
echo "$formatter rewrote $CHANGELOG, running it again to confirm the result is stable"
"$prek" run "$formatter" --files "$CHANGELOG"
Expand Down
4 changes: 1 addition & 3 deletions publish-github-release/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -151,8 +151,6 @@ runs:
set -euo pipefail
# shellcheck disable=SC1091 # GitHub supplies this Action path at runtime.
source "$GITHUB_ACTION_PATH/retry.sh"
# shellcheck disable=SC1091 # GitHub supplies this Action path at runtime.
source "$GITHUB_ACTION_PATH/../tools/plural.sh"
if [[ "$MODE" == publish ]]; then
echo 'Asset upload skipped: mode publish only publishes the staged draft'
elif [[ -z "$ASSETS_DIRECTORY" ]]; then
Expand Down Expand Up @@ -223,7 +221,7 @@ runs:
uploaded=$((uploaded + 1))
fi
done
echo "GitHub Release $TAG: uploaded $(plural "$uploaded" asset), $present already present"
echo "GitHub Release $TAG: uploaded $(python3 "$GITHUB_ACTION_PATH/../tools/plural.py" "$uploaded" asset), $present already present"
fi
fi
- name: 🔍 Verify the complete asset set
Expand Down
4 changes: 1 addition & 3 deletions publish-post-release-pr/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,6 @@ runs:
ALLOWED_PATHS: ${{ inputs.allowed-paths }}
run: |
set -euo pipefail
# shellcheck disable=SC1091 # GitHub supplies this Action path at runtime.
source "$GITHUB_ACTION_PATH/../tools/plural.sh"
trusted_paths=()
while IFS= read -r path || [[ -n "$path" ]]; do
trusted_paths+=("$path")
Expand Down Expand Up @@ -77,7 +75,7 @@ runs:
exit 1
fi
printf '%s\n' "${paths[@]}" | LC_ALL=C sort >"$RUNNER_TEMP/expected-paths"
echo "Prepared paths: $(plural "${#paths[@]}" path) allowed by the writer: ${paths[*]}"
echo "Prepared paths: $(python3 "$GITHUB_ACTION_PATH/../tools/plural.py" "${#paths[@]}" path) allowed by the writer: ${paths[*]}"
- name: 🔍 Verify the prepared commit
shell: bash
env:
Expand Down
2 changes: 1 addition & 1 deletion reconcile-pypi-artifacts/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ runs:
EXCLUDE_NEWER: ${{ inputs.exclude-newer }}
EXCLUDE_NEWER_PACKAGE: ${{ inputs.exclude-newer-package }}
run: >-
"$GITHUB_ACTION_PATH/../tools/install_live_tools.sh" "$RUNNER_TEMP/pypi-attestations" 'certifi>=2026.7.22' 'pypi-attestations>=0.0.30'
"$GITHUB_ACTION_PATH/../tools/install_live_tools.py" "$RUNNER_TEMP/pypi-attestations" 'certifi>=2026.7.22' 'pypi-attestations>=0.0.30'
- name: 🔍 Compare published files
shell: bash
env:
Expand Down
2 changes: 1 addition & 1 deletion refresh-release-lock/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ runs:
else
echo "Nab: ${requirements[0]} (refresh mode takes the newest release under the cutoff)"
fi
"$GITHUB_ACTION_PATH/../tools/install_live_tools.sh" "$RUNNER_TEMP/lock-tools" "${requirements[@]}"
"$GITHUB_ACTION_PATH/../tools/install_live_tools.py" "$RUNNER_TEMP/lock-tools" "${requirements[@]}"
- name: 🔒 Resolve all project groups and extras
shell: bash
env:
Expand Down
4 changes: 1 addition & 3 deletions render-release-notes/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,6 @@ runs:
PACKAGE: ${{ inputs.package }}
run: |
set -euo pipefail
# shellcheck disable=SC1091 # GitHub supplies this Action path at runtime.
source "$GITHUB_ACTION_PATH/../tools/plural.sh"
args=(--changelog "$CHANGELOG" --tag "$TAG" --repository "$GITHUB_REPOSITORY")
if [[ -n "$PREVIOUS" ]]; then
args+=(--previous "$PREVIOUS")
Expand All @@ -80,4 +78,4 @@ runs:
release-creator notes "${args[@]}" >"$RUNNER_TEMP/release-notes.md"
entries=$(grep -c '^- ' "$RUNNER_TEMP/release-notes.md" || true)
categories=$(grep -c '^### ' "$RUNNER_TEMP/release-notes.md" || true)
echo "Release notes for $TAG: $(plural "$entries" entry entries) in $(plural "$categories" category categories) from $CHANGELOG -> $RUNNER_TEMP/release-notes.md"
echo "Release notes for $TAG: $(python3 "$GITHUB_ACTION_PATH/../tools/plural.py" "$entries" entry entries) in $(python3 "$GITHUB_ACTION_PATH/../tools/plural.py" "$categories" category categories) from $CHANGELOG -> $RUNNER_TEMP/release-notes.md"
4 changes: 0 additions & 4 deletions tests/bash32_arrays/test_empty_arrays.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,6 @@ def test_unguarded_array_expansions_are_provably_non_empty() -> None:
("refresh-release-lock/action.yml", "selected_groups"): "used only inside the non-empty check",
("render-release-notes/action.yml", "args"): "starts with three options",
("stage-jetbrains-marketplace/action.yml", "args"): "starts with the marketplace subcommand",
("tools/install_live_tools.sh", "options"): "starts with --exclude-newer",
("tools/install_live_tools.sh", "overrides"): "expanded only inside the :+ alternative",
}
found = {(path, name) for path, script in _run_scripts() for name in _unguarded_names(script)}

Expand All @@ -34,8 +32,6 @@ def _run_scripts() -> Iterator[tuple[str, str]]:
for step in step_list(at(load_yaml(action), "runs", "steps")):
if "run" in step:
yield action.relative_to(root).as_posix(), text(step["run"])
for script in sorted((root / "tools").glob("*.sh")):
yield script.relative_to(root).as_posix(), script.read_text()
for workflow in sorted(WORKFLOWS.glob("*.y*ml")):
for job_value in mapping(at(load_yaml(workflow), "jobs")).values():
job = mapping(job_value)
Expand Down
2 changes: 1 addition & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ def action_scripts(tmp_path_factory: pytest.TempPathFactory) -> Path:
def nab_executable(tmp_path_factory: pytest.TempPathFactory) -> Path:
environment = tmp_path_factory.mktemp("nab-tools")
subprocess.run(
[str(Path(__file__).resolve().parents[1] / "tools/install_live_tools.sh"), str(environment), "nab>=0.0.22"],
[str(Path(__file__).resolve().parents[1] / "tools/install_live_tools.py"), str(environment), "nab>=0.0.22"],
env=os.environ | {"EXCLUDE_NEWER": "7 days"},
check=True,
capture_output=True,
Expand Down
84 changes: 77 additions & 7 deletions tests/cooled_prek/test_cooled_prek.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
from __future__ import annotations

import os
import re
import shlex
Expand All @@ -14,7 +12,7 @@
from conftest import action
from pytest_httpserver import HTTPServer

SCRIPT = Path(__file__).resolve().parents[2] / "tools/cooled_prek.sh"
SCRIPT = Path(__file__).resolve().parents[2] / "tools/cooled_prek.py"


def _fake_uv(directory: Path, httpserver: HTTPServer) -> None:
Expand Down Expand Up @@ -42,7 +40,10 @@ def route_open(request, *args, **kwargs):
"""
)
)
code = f"import sys; sys.path.insert(0, {str(site)!r}); import sitecustomize; exec(sys.stdin.read())"
code = (
f"import coverage, sys; coverage.process_startup(); sys.path.insert(0, {str(site)!r}); "
f"import sitecustomize; exec(compile(sys.stdin.read(), {str(SCRIPT.with_name('hook_pin_cutoffs.py'))!r}, 'exec'))"
)
fake = directory / "uv"
fake.write_text(f"#!/bin/sh\nexec {sys.executable!r} -I -c {shlex.quote(code)}\n")
fake.chmod(0o755)
Expand Down Expand Up @@ -111,6 +112,7 @@ def test_cooled_prek_action_passes_each_argument(tmp_path: Path, action_scripts:
("arguments", "install"),
[
pytest.param(["run", "--all-files"], ["install-hooks"], id="every-hook"),
pytest.param(["run", "mdformat"], ["install-hooks", "mdformat"], id="named-hook-without-options"),
pytest.param(["run", "mdformat", "--files", "CHANGELOG.md"], ["install-hooks", "mdformat"], id="named-hook"),
],
)
Expand All @@ -126,7 +128,7 @@ def test_cooled_prek_builds_hooks_cooled_and_runs_them_with_the_caller_environme
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"), *arguments],
[str(Path(__file__).resolve().parents[2] / "tools/cooled_prek.py"), *arguments],
cwd=tmp_path,
env=os.environ | leaked | {"PATH": f"{tmp_path}:{os.environ['PATH']}", "UV_EXCLUDE_NEWER": "1 day"},
check=True,
Expand All @@ -153,6 +155,25 @@ def test_cooled_prek_builds_hooks_cooled_and_runs_them_with_the_caller_environme
assert leaked.items() <= run_env.items()


def test_cooled_prek_stops_when_pin_discovery_fails(tmp_path: Path) -> None:
for name, body in (("uv", "exit 7"), ("prek", "touch ran")):
executable = tmp_path / name
executable.write_text(f"#!/bin/sh\n{body}\n")
executable.chmod(0o755)

result = subprocess.run(
[str(SCRIPT), "run", "--all-files"],
cwd=tmp_path,
env=os.environ | {"PATH": f"{tmp_path}:{os.environ['PATH']}"},
capture_output=True,
text=True,
check=False,
)

assert result.returncode == 7
assert not (tmp_path / "ran").exists()


def test_cooled_prek_admits_nab_cutoff_exceptions(tmp_path: Path, httpserver: HTTPServer) -> None:
fake = tmp_path / "prek"
fake.write_text('#!/bin/sh\ncat "$UV_CONFIG_FILE" >config\n')
Expand All @@ -164,7 +185,7 @@ def test_cooled_prek_admits_nab_cutoff_exceptions(tmp_path: Path, httpserver: HT
)

subprocess.run(
[str(Path(__file__).resolve().parents[2] / "tools/cooled_prek.sh"), "install-hooks"],
[str(Path(__file__).resolve().parents[2] / "tools/cooled_prek.py"), "install-hooks"],
cwd=tmp_path,
env=os.environ | {"PATH": f"{tmp_path}:{os.environ['PATH']}"},
check=True,
Expand All @@ -185,7 +206,7 @@ def test_cooled_prek_without_exceptions_sets_no_config_file(tmp_path: Path, http
(tmp_path / "pyproject.toml").write_text('[tool.nab]\nuploaded-prior-to = "P7D"\n')

subprocess.run(
[str(Path(__file__).resolve().parents[2] / "tools/cooled_prek.sh"), "install-hooks"],
[str(Path(__file__).resolve().parents[2] / "tools/cooled_prek.py"), "install-hooks"],
cwd=tmp_path,
env=os.environ | {"PATH": f"{tmp_path}:{os.environ['PATH']}", "UV_CONFIG_FILE": "/leak/uv.toml"},
check=True,
Expand Down Expand Up @@ -251,6 +272,18 @@ def _admitted(uploaded: datetime) -> str:

PIN_SOURCES = [
pytest.param({"setup.py": 'setup(install_requires=["simplint==0.7.1"])\n'}, {"language": "python"}, id="setup-py"),
pytest.param(
{"setup.py": 'SIMPLINT_VERSION = "0.7.1"\nsetup(install_requires=[f"simplint=={SIMPLINT_VERSION}"])\n'},
{"language": "python"},
id="setup-py-fstring",
),
pytest.param(
{
"setup.py": 'VERSION = SIMPLINT_VERSION = "0.7.1"\nsetup(install_requires=[f"simplint=={SIMPLINT_VERSION}"])\n'
},
{"language": "python"},
id="setup-py-chained-fstring",
),
pytest.param(
{"setup.cfg": "[options]\ninstall_requires =\n simplint==0.7.1\n"}, {"language": "python"}, id="setup-cfg"
),
Expand Down Expand Up @@ -306,6 +339,31 @@ def test_cooled_prek_admits_pin_up_to_its_upload(
{"language": "system"},
id="non-python-metadata",
),
pytest.param(
{"setup.py": 'VERSION = "0.7.1"\nsetup(install_requires=[VERSION])\n'},
{"language": "python"},
id="nonliteral-requirement",
),
pytest.param(
{"setup.py": 'setup(install_requires=[f"simplint=={VERSION}"])\n'},
{"language": "python"},
id="unknown-fstring-value",
),
pytest.param(
{"setup.py": 'settings.VERSION = "0.7.1"\nsetup(install_requires=[f"simplint=={VERSION}"])\n'},
{"language": "python"},
id="attribute-constant",
),
pytest.param(
{"setup.py": 'setup(install_requires=[f"simplint=={version()}"])\n'},
{"language": "python"},
id="dynamic-fstring-value",
),
pytest.param(
{"setup.py": 'VERSION = "0.7.1"\nsetup(install_requires=[f"simplint=={VERSION!r}"])\n'},
{"language": "python"},
id="formatted-fstring-value",
),
pytest.param(
{"setup.py": 'setup(extras_require={"test": ["simplint==0.7.1"]}, tests_require=["simplint==0.7.1"])\n'},
{"language": "python"},
Expand Down Expand Up @@ -411,6 +469,18 @@ def test_cooled_prek_keeps_nab_duration_without_pin(tmp_path: Path, httpserver:
assert (tmp_path / "config").read_text().splitlines() == ["[exclude-newer-package]", '"sphinx" = "P3D"']


def test_cooled_prek_ignores_local_hooks(tmp_path: Path, httpserver: HTTPServer) -> None:
(tmp_path / ".pre-commit-config.yaml").write_text(
yaml.safe_dump({"repos": [{"repo": "local", "hooks": []}, {"repo": "meta", "hooks": []}]})
)

result = _cooled_prek(tmp_path, httpserver)

assert result.returncode == 0, result.stderr
assert (tmp_path / "config").read_text() == ""
assert httpserver.log == []


@pytest.mark.parametrize(
("status", "error"),
[
Expand Down
37 changes: 21 additions & 16 deletions tests/install_live_tools/test_install_live_tools.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
from __future__ import annotations

import json
import os
import subprocess
Expand All @@ -10,7 +8,14 @@
import pytest
from pytest_httpserver import HTTPServer

_SCRIPT: Final = Path(__file__).resolve().parents[2] / "tools/install_live_tools.sh"
_SCRIPT: Final = Path(__file__).resolve().parents[2] / "tools/install_live_tools.py"


def test_install_live_tools_requires_arguments() -> None:
result = subprocess.run([sys.executable, str(_SCRIPT)], capture_output=True, text=True, check=False)

assert result.returncode != 0
assert "usage: install_live_tools.py" in result.stderr


@pytest.fixture
Expand All @@ -26,18 +31,6 @@ def fake_uv(tmp_path: Path) -> Path:
return executable


def _install(tmp_path: Path, fake_uv: Path, **environment: str) -> list[dict[str, Any]]:
calls = tmp_path / "calls.jsonl"
subprocess.run(
[str(_SCRIPT), str(tmp_path / "tools"), "nab>=0.0.22", "packaging>=26.3"],
env=os.environ
| {"PATH": f"{fake_uv.parent}{os.pathsep}{os.environ['PATH']}", "UV_CALLS": str(calls)}
| environment,
check=True,
)
return [json.loads(line) for line in calls.read_text().splitlines()]


def test_install_live_tools_applies_the_cutoff(tmp_path: Path, fake_uv: Path) -> None:
venv, install = _install(tmp_path, fake_uv, EXCLUDE_NEWER="7 days")

Expand All @@ -54,6 +47,18 @@ def test_install_live_tools_applies_the_cutoff(tmp_path: Path, fake_uv: Path) ->
]


def _install(tmp_path: Path, fake_uv: Path, **environment: str) -> list[dict[str, Any]]:
calls = tmp_path / "calls.jsonl"
subprocess.run(
[str(_SCRIPT), str(tmp_path / "tools"), "nab>=0.0.22", "packaging>=26.3"],
env=os.environ
| {"PATH": f"{fake_uv.parent}{os.pathsep}{os.environ['PATH']}", "UV_CALLS": str(calls)}
| environment,
check=True,
)
return [json.loads(line) for line in calls.read_text().splitlines()]


def test_install_live_tools_passes_each_package_cutoff(tmp_path: Path, fake_uv: Path) -> None:
_, install = _install(
tmp_path,
Expand Down Expand Up @@ -95,7 +100,7 @@ def test_install_live_tools_requires_a_cutoff(tmp_path: Path, fake_uv: Path) ->
)

assert result.returncode != 0
assert "EXCLUDE_NEWER: unbound variable" in result.stderr
assert "EXCLUDE_NEWER is required" in result.stderr


def test_install_live_tools_ignores_inherited_uv_settings(tmp_path: Path, fake_uv: Path) -> None:
Expand Down
Loading