From 89940ac3dcdb4941b119404c988e8d868407b244 Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Tue, 14 Oct 2025 17:01:52 +0300 Subject: [PATCH 1/6] Refactor tests --- tests/test_autoupdate.py | 38 ++++++++++++++++++++++++++------------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/tests/test_autoupdate.py b/tests/test_autoupdate.py index ad70ab2..3ca54cb 100644 --- a/tests/test_autoupdate.py +++ b/tests/test_autoupdate.py @@ -6,6 +6,25 @@ from gha_tools.cli import main +def is_pinned(action: str, content: str) -> re.Match | None: + pat = rf"^\s+- uses: {action}@[0-9a-f]+\s+# v" + return re.search(pat, content, flags=re.MULTILINE) + + +def is_major_tag(action: str, content: str) -> re.Match | None: + pat = rf"^\s+- uses: {action}@v\d+\s" + return re.search(pat, content, flags=re.MULTILINE) + + +def is_specific_tag(action: str, content: str) -> re.Match | None: + pat = rf"^\s+- uses: {action}@v\d+\.\d+" + return re.search(pat, content, flags=re.MULTILINE) + + +def is_tag(action: str, content: str) -> re.Match | None: + return is_major_tag(action, content) or is_specific_tag(action, content) + + def test_autoupdate(victim_path): result = CliRunner().invoke( main, @@ -46,21 +65,13 @@ def test_autoupdate_pin(victim_path, pin): if pin == "third_party" and "actions/" in action: # When pinning only third-party actions, # first-party actions are just left as tags - assert re.search( - rf"^\s+- uses: {action}@v", - content, - flags=re.MULTILINE, - ) + assert is_tag(action, content) else: - assert re.search( - rf"^\s+- uses: {action}@[0-9a-f]+\s+# v", - content, - flags=re.MULTILINE, - ) + assert is_pinned(action, content) @pytest.mark.parametrize("pin", (False, True)) -def test_autoupdate_major(victim_path, pin): +def test_autoupdate_specific(victim_path, pin): uv_yml_path = victim_path / "uv.yml" result = CliRunner().invoke( main, @@ -74,4 +85,7 @@ def test_autoupdate_major(victim_path, pin): ) assert result.exit_code == 0 content = uv_yml_path.read_text() - assert re.search(r"(@|# )v\d+\.\d+", content) + if pin: + assert is_pinned("astral-sh/setup-uv", content) + else: + assert is_specific_tag("astral-sh/setup-uv", content) From 71ef8adf8edfbd76cbc1488abb9ca09e6b134608 Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Tue, 14 Oct 2025 17:03:04 +0300 Subject: [PATCH 2/6] Widen line-length --- gha_tools/action_updater.py | 7 ++----- pyproject.toml | 1 + 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/gha_tools/action_updater.py b/gha_tools/action_updater.py index 8723482..b6a423d 100644 --- a/gha_tools/action_updater.py +++ b/gha_tools/action_updater.py @@ -73,9 +73,7 @@ class ActionVersions: def from_github(cls, action_name: str) -> ActionVersions: log.debug("Fetching versions for %s...", action_name) try: - action_tags = get_github_json( - f"https://api.github.com/repos/{action_name}/tags", - ) + action_tags = get_github_json(f"https://api.github.com/repos/{action_name}/tags") except HTTPError as he: if he.status == 404: log.warning("Action %s (or tags for it) not found.", action_name) @@ -106,8 +104,7 @@ def get_major_version_for_action_version( if major_version := all_versions.get(prospective_major_version): return major_version raise NoVersionsFound( - f"Could not determine major version from {version.name!r}; " - f"none of {set(all_versions)} matched", + f"Could not determine major version from {version.name!r}; none of {set(all_versions)} matched", ) diff --git a/pyproject.toml b/pyproject.toml index 327ff43..79b05ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -78,6 +78,7 @@ markers = [ [tool.ruff] target-version = "py38" +line-length = 120 [tool.ruff.lint] select = [ From 726ab8344ca37ea3dc723751efe52902ef099ed9 Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Tue, 14 Oct 2025 17:03:06 +0300 Subject: [PATCH 3/6] Allow customizing first party pattern & separate version strategies --- README.md | 22 +++++++++++++++ gha_tools/action_updater.py | 41 ++++++++++++---------------- gha_tools/cli.py | 54 ++++++++++++++++++++++++++++++------- tests/test_autoupdate.py | 48 +++++++++++++++++++++++++++++++++ 4 files changed, 132 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 15a2714..ad041d2 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,8 @@ update the action versions to the latest available version. * You can use `--diff` to see what changes would be made. This can be used in conjunction with `--write`. * You can use `--version-strategy=specific` to update to a specific latest version tag instead of the major tag, e.g. `v1.2.3` instead of `v1`. The default is to use the major tag, when available. + * You can use `--first-party-version-strategy` and `--third-party-version-strategy` to set different version + strategies for first-party and third-party actions respectively. ```console $ gha-tools autoupdate --diff .github/workflows @@ -69,6 +71,26 @@ Updating .github/workflows/test.yml... + - uses: akx/pre-commit-uv-action@19e2cbdb93404ff82f52044f07306443bc0bff7a # v0.1.0 ``` +#### Separate version strategies + +You can use different version strategies for first-party and third-party actions. +For example, to keep first-party actions on major versions but use specific versions for third-party actions: + +```console +$ gha-tools autoupdate --first-party-version-strategy=major --third-party-version-strategy=specific --diff .github/workflows +Updating .github/workflows/test.yml... +--- .github/workflows/test.yml ++++ .github/workflows/test.yml +@@ -5,6 +5,6 @@ + steps: +- - uses: actions/checkout@v3 +- - uses: actions/setup-python@v4 +- - uses: codecov/codecov-action@v3 ++ - uses: actions/checkout@v5 ++ - uses: actions/setup-python@v6 ++ - uses: codecov/codecov-action@v5.5.1 +``` + ## GitHub Rate Limiting Since this tool uses the GitHub API, you may run into rate limiting issues. diff --git a/gha_tools/action_updater.py b/gha_tools/action_updater.py index b6a423d..6f0eadf 100644 --- a/gha_tools/action_updater.py +++ b/gha_tools/action_updater.py @@ -33,6 +33,14 @@ class PinStrategy(Enum): ALL = "all" +@dataclasses.dataclass(frozen=True, kw_only=True) +class ActionUpdateConfig: + first_party_version_strategy: VersionStrategy + third_party_version_strategy: VersionStrategy + first_party_pattern: re.Pattern + pin_strategy: PinStrategy + + def is_beta_or_rc(ver: str) -> bool: if "-beta" in ver: return True @@ -145,10 +153,6 @@ def __str__(self) -> str: def with_version_and_comment(self, version: str, comment: str | None) -> ActionSpec: return dataclasses.replace(self, version=version, comment=comment) - @property - def is_first_party(self) -> bool: - return self.name.startswith("actions/") or self.name.startswith("github/") - @dataclasses.dataclass(frozen=True) class ActionUpdate: @@ -183,8 +187,7 @@ def _fixup_use( match: re.Match, *, updates: list[ActionUpdate], - version_strategy: VersionStrategy, - pin_strategy: PinStrategy, + config: ActionUpdateConfig, ) -> str: action_name = match.group("uses") action_name = try_unquote(action_name) @@ -192,13 +195,15 @@ def _fixup_use( log.debug("Skipping workflow %s", action_name) return match.group(0) spec = ActionSpec.from_string(action_name) + is_first_party = bool(config.first_party_pattern.match(spec.name)) + version_strategy = config.first_party_version_strategy if is_first_party else config.third_party_version_strategy try: new_version = get_new_version_with_strategy(spec, version_strategy) except Exception: log.warning("Could not get new version for %s", spec, exc_info=True) else: - pin_to_sha = pin_strategy == PinStrategy.ALL or ( - pin_strategy == PinStrategy.THIRD_PARTY and not spec.is_first_party + pin_to_sha = config.pin_strategy == PinStrategy.ALL or ( + config.pin_strategy == PinStrategy.THIRD_PARTY and not is_first_party ) updated_spec = spec.with_version_and_comment( version=new_version.commit_sha if pin_to_sha else new_version.name, @@ -228,16 +233,10 @@ def get_action_updates_for_text( content: str, *, path: Path | None = None, - version_strategy: VersionStrategy = VersionStrategy.MAJOR, - pin_strategy: PinStrategy = PinStrategy.NONE, + config: ActionUpdateConfig, ) -> ActionUpdateResult: updates: list[ActionUpdate] = [] - fixer = partial( - _fixup_use, - updates=updates, - version_strategy=version_strategy, - pin_strategy=pin_strategy, - ) + fixer = partial(_fixup_use, updates=updates, config=config) new_content = uses_regexp.sub(fixer, content) return ActionUpdateResult( path=path, @@ -250,12 +249,6 @@ def get_action_updates_for_text( def get_action_updates_for_path( path: Path, *, - version_strategy: VersionStrategy = VersionStrategy.MAJOR, - pin_strategy: PinStrategy = PinStrategy.NONE, + config: ActionUpdateConfig, ) -> ActionUpdateResult: - return get_action_updates_for_text( - path.read_text(), - path=path, - version_strategy=version_strategy, - pin_strategy=pin_strategy, - ) + return get_action_updates_for_text(path.read_text(), path=path, config=config) diff --git a/gha_tools/cli.py b/gha_tools/cli.py index 39a4652..4add350 100644 --- a/gha_tools/cli.py +++ b/gha_tools/cli.py @@ -1,11 +1,14 @@ from __future__ import annotations import logging +import re from pathlib import Path +from typing import cast import click from gha_tools.action_updater import ( + ActionUpdateConfig, PinStrategy, VersionStrategy, get_action_updates_for_path, @@ -32,7 +35,10 @@ def main( help="Update action versions.", context_settings={ # This is a bit of a hack, but hey... - "token_normalize_func": lambda x: x.replace("third-party", "third_party"), + "token_normalize_func": lambda x: x.replace( + "third-party", + "third_party", + ).replace("first-party", "first_party"), }, ) @click.argument("files", nargs=-1, type=click.Path(exists=True, path_type=Path)) @@ -42,8 +48,20 @@ def main( "--version-strategy", "-s", type=click.Choice(VersionStrategy, case_sensitive=False), - help="Version strategy to use.", - default=VersionStrategy.MAJOR.value, + help="Version strategy to use for both first-party and third-party actions.", + default=None, +) +@click.option( + "--first-party-version-strategy", + type=click.Choice(VersionStrategy, case_sensitive=False), + help="Version strategy to use for first-party actions.", + default=None, +) +@click.option( + "--third-party-version-strategy", + type=click.Choice(VersionStrategy, case_sensitive=False), + help="Version strategy to use for third-party actions.", + default=None, ) @click.option( "--pin-strategy", @@ -52,26 +70,44 @@ def main( help="Pinning strategy to use.", default=PinStrategy.NONE.value, ) +@click.option( + "--first-party-pattern", + type=str, + help="Regular expression pattern to match first-party actions (default: %(default)s).", + default=r"^(actions|github)/", +) def autoupdate( *, files: list[Path], diff: bool, write: bool, - version_strategy: VersionStrategy, + version_strategy: VersionStrategy | None, + first_party_version_strategy: VersionStrategy | None, + third_party_version_strategy: VersionStrategy | None, pin_strategy: PinStrategy, + first_party_pattern: str, ) -> None: actual_files = list(find_files(files)) if not actual_files: raise click.UsageError("No files or directories specified.") + config = ActionUpdateConfig( + first_party_version_strategy=cast( + VersionStrategy, + first_party_version_strategy or version_strategy or VersionStrategy.MAJOR, + ), + third_party_version_strategy=cast( + VersionStrategy, + third_party_version_strategy or version_strategy or VersionStrategy.MAJOR, + ), + pin_strategy=pin_strategy, + first_party_pattern=re.compile(first_party_pattern), + ) + for file in actual_files: log.info(f"Updating {file}...") - result = get_action_updates_for_path( - file, - version_strategy=version_strategy, - pin_strategy=pin_strategy, - ) + result = get_action_updates_for_path(file, config=config) if not result.changes: log.info(f" No changes to {file}.") continue diff --git a/tests/test_autoupdate.py b/tests/test_autoupdate.py index 3ca54cb..825e98b 100644 --- a/tests/test_autoupdate.py +++ b/tests/test_autoupdate.py @@ -89,3 +89,51 @@ def test_autoupdate_specific(victim_path, pin): assert is_pinned("astral-sh/setup-uv", content) else: assert is_specific_tag("astral-sh/setup-uv", content) + + +def test_separate_version_strategies(victim_path): + test_yml_path = victim_path / "test.yml" + result = CliRunner().invoke( + main, + [ + "autoupdate", + "--write", + "--first-party-version-strategy=major", + "--third-party-version-strategy=specific", + str(test_yml_path), + ], + ) + assert result.exit_code == 0 + content = test_yml_path.read_text() + + # First-party actions (actions/checkout, actions/setup-python) should use major versions + assert is_major_tag("actions/checkout", content) + assert is_major_tag("actions/setup-python", content) + # Third-party actions (codecov/codecov-action) should use specific versions + assert is_specific_tag("codecov/codecov-action", content) + + +def test_custom_first_party_pattern(victim_path): + test_yml_path = victim_path / "test.yml" + # Test using a custom pattern that treats codecov as first-party + result = CliRunner().invoke( + main, + [ + "autoupdate", + "--write", + "--first-party-version-strategy=major", + "--third-party-version-strategy=specific", + "--first-party-pattern=^(actions|github|codecov)/", + str(test_yml_path), + ], + ) + assert result.exit_code == 0 + content = test_yml_path.read_text() + + # All actions should use major versions (since codecov is now first-party) + for action in ( + "actions/checkout", + "actions/setup-python", + "codecov/codecov-action", + ): + assert is_major_tag(action, content) From 61e52c3fd165421a5b49ba4ce5b82382f96f875c Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Tue, 14 Oct 2025 17:13:13 +0300 Subject: [PATCH 4/6] Cache GitHub responses in tests --- gha_tools/github_api.py | 10 +++++++++- tests/test_autoupdate.py | 12 ++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/gha_tools/github_api.py b/gha_tools/github_api.py index 23afa23..d98b7b0 100644 --- a/gha_tools/github_api.py +++ b/gha_tools/github_api.py @@ -9,10 +9,15 @@ from gha_tools.__about__ import __version__ +# Optional cache; see `conftest.py`. +cache: dict[str, Any] | None = None + def get_github_json(url: str) -> Any: if not url.startswith("https://api.github.com/"): raise ValueError("URL must be a GitHub API URL") + if cache is not None and url in cache: + return cache[url] request = urllib.request.Request( url, headers={ @@ -30,4 +35,7 @@ def get_github_json(url: str) -> Any: content = f.read().decode("utf-8", "replace") if f.status != 200: raise HTTPError(url, f.status, f.reason, f.headers, None) - return json.loads(content) + data = json.loads(content) + if cache is not None: + cache[url] = data + return data diff --git a/tests/test_autoupdate.py b/tests/test_autoupdate.py index 825e98b..ff1c4db 100644 --- a/tests/test_autoupdate.py +++ b/tests/test_autoupdate.py @@ -6,6 +6,18 @@ from gha_tools.cli import main +@pytest.fixture(autouse=True, scope="module") +def cache_github_api(): + from gha_tools import github_api + + github_api.cache = {} + try: + yield + finally: + assert github_api.cache # did use cache? + github_api.cache = None + + def is_pinned(action: str, content: str) -> re.Match | None: pat = rf"^\s+- uses: {action}@[0-9a-f]+\s+# v" return re.search(pat, content, flags=re.MULTILINE) From cc49edf35739028afeafc7dfa2d4ba82b0c1af25 Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Tue, 14 Oct 2025 17:18:48 +0300 Subject: [PATCH 5/6] Ditch hatch envs for tests --- .github/workflows/test.yml | 2 +- pyproject.toml | 12 ------------ 2 files changed, 1 insertion(+), 13 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9d3bf6d..5c28c74 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -34,4 +34,4 @@ jobs: - uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6.8.0 with: python-version: "3.10" - - run: uvx hatch run cov + - run: uv run pytest --cov-report=term-missing --cov=gha_tools --cov=tests diff --git a/pyproject.toml b/pyproject.toml index 79b05ac..6a5da6f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,18 +44,6 @@ dev = [ [tool.hatch.version] path = "gha_tools/__about__.py" -[tool.hatch.envs.default] -dependencies = [ - "pytest", - "pytest-cov", -] -[tool.hatch.envs.default.scripts] -cov = "pytest --cov-report=term-missing --cov-config=pyproject.toml --cov=gha_tools --cov=tests {args}" -no-cov = "cov --no-cov {args}" - -[[tool.hatch.envs.test.matrix]] -python = ["310", "311", "312", "313"] - [tool.coverage.run] branch = true parallel = true From 3ab61d98109a8b3007bab77c552f3abcb721df65 Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Tue, 14 Oct 2025 17:19:05 +0300 Subject: [PATCH 6/6] Declare support for Python 3.14 --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 6a5da6f..7a31172 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", ]