+
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
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
48 changes: 19 additions & 29 deletions gha_tools/action_updater.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -73,9 +81,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)
Expand Down Expand Up @@ -106,8 +112,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",
)


Expand Down Expand Up @@ -148,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:
Expand Down Expand Up @@ -186,22 +187,23 @@ 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)
if ".github/" in action_name:
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,
Expand Down Expand Up @@ -231,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,
Expand All @@ -253,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)
54 changes: 45 additions & 9 deletions gha_tools/cli.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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))
Expand All @@ -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",
Expand All @@ -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
Expand Down
10 changes: 9 additions & 1 deletion gha_tools/github_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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={
Expand All @@ -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
14 changes: 2 additions & 12 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]
Expand All @@ -44,18 +45,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
Expand All @@ -78,6 +67,7 @@ markers = [

[tool.ruff]
target-version = "py38"
line-length = 120

[tool.ruff.lint]
select = [
Expand Down
Loading
Loading
点击 这是indexloc提供的php浏览器服务,不要输入任何密码和下载