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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
# Unreleased

* [#74](https://github.com/saadmk11/github-actions-version-updater/issues/74): When `update_version_with` is `release-commit-sha`, write the matching release tag as an inline `# tag` comment (exactly two spaces before `#`) so SHA-to-SHA diffs stay human-readable. Version-like comments are updated; custom comments are left alone.

# Version: v1.0.0

v1 is a rewrite. The GitHub Action is now a composite Action that installs and runs the ``update-gha`` CLI. Action input *names* are the same. How files are found, how pins are rewritten, and what the runner needs are not.
Expand Down
14 changes: 9 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
[![GitHub Marketplace](https://img.shields.io/badge/Get%20It-on%20Marketplace-orange?style=flat-square)](https://github.com/marketplace/actions/github-actions-version-updater)
[![PyPI](https://img.shields.io/pypi/v/update-gha?style=flat-square)](https://pypi.org/project/update-gha/)

Scans workflow YAML for `uses:` pins, asks GitHub for a newer release tag, release commit, or default-branch SHA, and rewrites only the version token. Quotes, comments, and line endings stay as they are. Run it as a scheduled Action that opens a pull request, or as the `update-gha` CLI on your machine.
Scans workflow YAML for `uses:` pins, asks GitHub for a newer release tag, release commit, or default-branch SHA, and rewrites only the version token. Quotes, user comments, and line endings stay as they are. SHA pins also get a `# tag` comment so the version stays readable. Run it as a scheduled Action that opens a pull request, or as the `update-gha` CLI on your machine.

| | [GitHub Action](#github-action) | [Python package](#python-package) |
| --- | --- | --- |
Expand Down Expand Up @@ -64,7 +64,8 @@ Like Dependabot, but only for GitHub Actions:

- Finds `uses:` pins in `.github/workflows` and any extra paths you pass
- Looks up a newer release tag, release commit, or default-branch SHA
- Rewrites **only** the version token — quotes, comments, and line endings stay as they are
- Rewrites **only** the version token — quotes, user comments, and line endings stay as they are
- When pinning a release commit SHA, adds or updates a `# tag` comment so the diff shows a human-readable version
- Commits the result and opens a pull request (unless you set `skip_pull_request`)

Local actions (`./path`) and container actions (`docker://…`) are not updated.
Expand Down Expand Up @@ -117,9 +118,11 @@ jobs:
| Value | What is written | Example |
| --- | --- | --- |
| `release-tag` (default) | Latest published stable release tag | `actions/checkout@v4.2.2` |
| `release-commit-sha` | Commit that the latest stable tag points at | `actions/checkout@11bd7190…` |
| `release-commit-sha` | Commit that the latest stable tag points at, plus a `# tag` comment | `actions/checkout@11bd7190… # v4.2.2` |
| `default-branch-sha` | Latest commit on the action's default branch | `actions/checkout@11bd7190…` |

A `# tag` comment makes SHA-to-SHA diffs readable (`# v4.1.0` → `# v4.2.2`). The comment is written with two spaces before `#`. If the line already has a version-like comment, it is updated to that form. A human comment such as `# pin for security` is left alone.

### Release types

`release_types` limits which SemVer bumps are applied. It only applies to `release-tag` and `release-commit-sha`.
Expand Down Expand Up @@ -281,7 +284,8 @@ If the repository uses [Git LFS](https://git-lfs.github.com/), check out with `l
[PyPI](https://pypi.org/project/update-gha/) · [Changelog](CHANGELOG.md) · [Issues](https://github.com/saadmk11/github-actions-version-updater/issues)

- Scans `.github/workflows` plus extra files or directories
- Rewrites only the version token (YAML structure, quotes, comments, and line endings stay)
- Rewrites only the version token (YAML structure, quotes, user comments, and line endings stay)
- SHA pins written with `release-commit-sha` get a `# tag` comment (the matching release tag)
- Three version sources: release tag, release commit SHA, default-branch SHA
- SemVer filters (`major` / `minor` / `patch`)
- `--check`, `--dry-run`, `--diff`, `--fail-on-update`, and JSON output
Expand Down Expand Up @@ -384,7 +388,7 @@ You can also enable pull-request mode with `GHA_UPDATE_CREATE_PULL_REQUEST=true`
| --- | --- | --- |
| `--token` | GitHub token. Required for `--pull-request` and private action repos; optional for public lookups. Also `GITHUB_TOKEN` / `GHA_UPDATE_TOKEN`. | unset |
| `--ignore` | Comma-separated exact `uses` pins to skip, including the current version. | empty |
| `--update-version-with` | `release-tag`, `release-commit-sha`, or `default-branch-sha`. | `release-tag` |
| `--update-version-with` | `release-tag`, `release-commit-sha` (SHA plus a `# tag` comment), or `default-branch-sha`. | `release-tag` |
| `--release-types` | `major`, `minor`, `patch`, or `all`. No effect on `default-branch-sha`. | `all` |
| `--extra-workflow-locations` | Extra files or directories, comma-separated. Directories are recursive. | empty |
| `PATHS` | Extra files or directories as positional arguments. Same role as `--extra-workflow-locations`. | none |
Expand Down
2 changes: 1 addition & 1 deletion action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ inputs:
required: false
default: 'false'
update_version_with:
description: 'Choose the update source: "release-tag" (default), "release-commit-sha", or "default-branch-sha"'
description: 'Choose the update source: "release-tag" (default), "release-commit-sha" (SHA plus a # tag comment), or "default-branch-sha"'
required: false
default: 'release-tag'
release_types:
Expand Down
5 changes: 3 additions & 2 deletions src/update_gha/cli/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,9 @@
"--update-version-with",
help=(
"What replaces each pin. release-tag (default) writes the latest "
"stable tag; release-commit-sha writes that tag's commit; "
"default-branch-sha writes the tip of the action's default branch."
"stable tag; release-commit-sha writes that tag's commit and a "
"# tag comment; default-branch-sha writes the tip of the "
"action's default branch."
),
metavar="SOURCE",
rich_help_panel=_UPDATE,
Expand Down
25 changes: 20 additions & 5 deletions src/update_gha/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,11 @@ def markdown_line(self) -> str:
):
release = resolved.release
commit = resolved.commit
if self.old_version == self.new_version:
return (
f"{start} updated the release tag comment to "
f"**[{release.tag_name}]({release.html_url})**\n"
)
return (
f"{start} added a new "
f"**[commit]({commit.url})** to "
Expand Down Expand Up @@ -175,11 +180,21 @@ def text_summary(self) -> str:
if not (updates := self.action_updates):
return "Everything is up-to-date."
width = max(len(update.location) for update in updates)
lines = [
f"{update.location:<{width}} "
f"{update.old_version} -> {update.new_version}"
for update in updates
]
lines: list[str] = []
for update in updates:
if (
update.old_version == update.new_version
and update.resolved.release is not None
):
lines.append(
f"{update.location:<{width}} "
f"{update.new_version} ({update.resolved.release.tag_name})"
)
else:
lines.append(
f"{update.location:<{width}} "
f"{update.old_version} -> {update.new_version}"
)
file_count = sum(1 for file_update in self.files if file_update.changed)
lines.extend(("", f"Updated {len(updates)} actions in {file_count} files."))
return "\n".join(lines)
Expand Down
147 changes: 127 additions & 20 deletions src/update_gha/rewrite.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
"""Discover and rewrite ``uses:`` pins without re-serializing YAML."""

from collections.abc import Mapping
from dataclasses import dataclass

from packaging.version import InvalidVersion, Version
from yaml import YAMLError, compose_all
from yaml.nodes import MappingNode, Node, ScalarNode, SequenceNode

from update_gha.models import ActionRef, VersionToken

type UsesSpan = tuple[int, int, str, str | None]

_BLOCK_SCALAR_STYLES = frozenset({"|", ">"})


def actions_from_spans(spans: tuple[UsesSpan, ...]) -> frozenset[str]:
"""Unique ``uses`` values from already-parsed spans."""
Expand All @@ -23,13 +27,35 @@ def get_all_actions(text: str) -> frozenset[str] | None:
return actions_from_spans(spans)


@dataclass(frozen=True, slots=True)
class PinRewrite:
"""What to write for one ``uses:`` pin.

``version`` is the new pin token (tag or SHA). ``comment``, when set,
is the inline YAML comment written after the pin — the release tag
when pinning a commit SHA. ``None`` leaves any existing comment as
it is.
"""

version: VersionToken
comment: str | None = None


type PinRewriteSpec = VersionToken | PinRewrite


def apply_version_updates(
original_text: str,
updates: Mapping[ActionRef, VersionToken],
updates: Mapping[ActionRef, PinRewriteSpec],
*,
spans: tuple[UsesSpan, ...] | None = None,
) -> str:
"""Return ``original_text`` with listed action versions replaced."""
"""Return ``original_text`` with listed action versions replaced.

A :class:`PinRewrite` ``comment`` becomes a same-line `` # tag`` on
single-line plain/quoted pins. Version-like comments are replaced;
custom comments, block scalars, and flow values are left alone.
"""
if not updates:
return original_text

Expand All @@ -38,36 +64,117 @@ def apply_version_updates(
if resolved_spans is None:
return original_text
for start, end, action, style in resolved_spans:
if (new_version := updates.get(action)) is None:
if (spec := updates.get(action)) is None:
continue
rewrite = _as_pin_rewrite(spec)
location, separator, old_version = action.rpartition("@")
if not separator or not location or new_version == old_version:
if not separator or not location:
continue
version_changed = rewrite.version != old_version
if not version_changed and rewrite.comment is None:
continue
raw_scalar = original_text[start:end]
updated_action = f"{location}@{new_version}"
match style:
case "'":
encoded_action = updated_action.replace("'", "''")
case '"':
encoded_action = updated_action.replace("\\", "\\\\").replace(
'"', '\\"'
)
case None if any(character in ",[]{}" for character in updated_action):
escaped_action = updated_action.replace("'", "''")
encoded_action = f"'{escaped_action}'"
case _:
encoded_action = updated_action
updated_scalar = raw_scalar.replace(action, encoded_action, 1)
if updated_scalar == raw_scalar:
if version_changed:
updated_action = f"{location}@{rewrite.version}"
match style:
case "'":
encoded_action = updated_action.replace("'", "''")
case '"':
encoded_action = updated_action.replace("\\", "\\\\").replace(
'"', '\\"'
)
case None if any(character in ",[]{}" for character in updated_action):
escaped_action = updated_action.replace("'", "''")
encoded_action = f"'{escaped_action}'"
case _:
encoded_action = updated_action
updated_scalar = raw_scalar.replace(action, encoded_action, 1)
if updated_scalar == raw_scalar:
continue
else:
updated_scalar = raw_scalar

replacement = updated_scalar
replace_end = end
if rewrite.comment is not None:
annotated = _apply_release_tag_comment(
original_text,
scalar_start=start,
scalar_end=end,
style=style,
tag=rewrite.comment,
)
if annotated is not None:
suffix, suffix_len = annotated
replacement = updated_scalar + suffix
replace_end = end + suffix_len

if original_text[start:replace_end] == replacement:
continue
replacements[(start, end)] = updated_scalar
replacements[(start, replace_end)] = replacement

result = original_text
for (start, end), replacement in sorted(replacements.items(), reverse=True):
result = f"{result[:start]}{replacement}{result[end:]}"
return result


def _as_pin_rewrite(spec: PinRewriteSpec) -> PinRewrite:
if isinstance(spec, PinRewrite):
return spec
return PinRewrite(version=spec)


def _apply_release_tag_comment(
text: str,
*,
scalar_start: int,
scalar_end: int,
style: str | None,
tag: str,
) -> tuple[str, int] | None:
"""Return ``(new_suffix, old_suffix_len)`` after the scalar, or ``None``.

``None`` means the pin cannot be annotated in place: the tag is
unsafe, the scalar is a block or multi-line value, or more YAML
tokens follow on the same line (flow style).
"""
tag = tag.strip()
if (
not tag
or "\n" in tag
or "\r" in tag
or style in _BLOCK_SCALAR_STYLES
or any(character in text[scalar_start:scalar_end] for character in "\r\n")
):
return None

line, _nl, _rest = text[scalar_end:].partition("\n")
suffix = line[:-1] if line.endswith("\r") else line
comment_at = suffix.find("#")
before_hash = suffix if comment_at < 0 else suffix[:comment_at]
if before_hash.strip():
return None

desired = f" # {tag}"
if comment_at < 0:
return desired, len(suffix)
body = suffix[comment_at + 1 :].strip()
if body == tag or _is_version_comment(body):
return desired, len(suffix)
return None


def _is_version_comment(body: str) -> bool:
if not body:
return False
try:
Version(body)
except InvalidVersion:
return False
return True


def parse_uses_spans(text: str) -> tuple[UsesSpan, ...] | None:
"""Return source spans for scalar values assigned to a ``uses`` key."""
found: dict[tuple[int, int], tuple[str, str | None]] = {}
Expand Down
40 changes: 33 additions & 7 deletions src/update_gha/scan.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
UpdateVersionWith,
)
from update_gha.rewrite import (
PinRewrite,
actions_from_spans,
apply_version_updates,
parse_uses_spans,
Expand Down Expand Up @@ -155,7 +156,7 @@ def _update_one_file(
return None
actions = set(actions_from_spans(spans)) - config.ignore_actions

updates: dict[str, str] = {}
updates: dict[str, PinRewrite] = {}
action_updates: list[ActionUpdate] = []

for action in sorted(actions):
Expand Down Expand Up @@ -183,13 +184,19 @@ def _update_one_file(
continue

updated_action = f"{action_location}@{resolved.version}"
if action == updated_action:
comment = _release_tag_comment(config.update_version_with, resolved)
version_changed = action != updated_action
if not version_changed and comment is None:
reporter.info(f'No updates found for "{action_repository}"')
continue

reporter.info(f'Found new version for "{action_repository}"')
reporter.info(f'Updating "{action}" with "{updated_action}"...')
updates[action] = resolved.version
if version_changed:
reporter.info(f'Found new version for "{action_repository}"')
reporter.info(f'Updating "{action}" with "{updated_action}"...')
updates[action] = PinRewrite(
version=resolved.version,
comment=comment,
)
action_updates.append(
ActionUpdate(
repository=action_repository,
Expand Down Expand Up @@ -221,11 +228,12 @@ def _update_one_file(
applied = _applied_updates(
actions_from_spans(found_after), action_updates, reporter, workflow_path
)
changed = updated_text != original and bool(applied)
return FileUpdate(
path=workflow_path,
original=original,
updated=updated_text if applied else original,
actions=applied,
updated=updated_text if changed else original,
actions=applied if changed else (),
)


Expand Down Expand Up @@ -283,6 +291,24 @@ def _applied_updates(
return tuple(applied)


def _release_tag_comment(
update_with: UpdateVersionWith, resolved: ResolvedVersion
) -> str | None:
"""Tag to write as a ``# tag`` comment on SHA pins, if any.

Only ``release-commit-sha`` has a corresponding release tag. The
tag is skipped when it would not be a safe single-line comment.
"""
if update_with is not UpdateVersionWith.LATEST_RELEASE_COMMIT_SHA:
return None
if resolved.release is None:
return None
tag = resolved.release.tag_name.strip()
if not tag or "\n" in tag or "\r" in tag:
return None
return tag


def _split_action(action: str) -> tuple[str, str] | None:
match action:
case s if s.startswith(("./", "docker://")):
Expand Down
Loading
Loading