diff --git a/CHANGELOG.md b/CHANGELOG.md index b3fda05..b274aef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index d93013a..0477c9f 100644 --- a/README.md +++ b/README.md @@ -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) | | --- | --- | --- | @@ -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. @@ -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`. @@ -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 @@ -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 | diff --git a/action.yaml b/action.yaml index 81b0d50..f5d4ed3 100644 --- a/action.yaml +++ b/action.yaml @@ -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: diff --git a/src/update_gha/cli/options.py b/src/update_gha/cli/options.py index 758cb4d..fec1457 100644 --- a/src/update_gha/cli/options.py +++ b/src/update_gha/cli/options.py @@ -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, diff --git a/src/update_gha/models.py b/src/update_gha/models.py index 9a6cea9..8159e2a 100644 --- a/src/update_gha/models.py +++ b/src/update_gha/models.py @@ -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 " @@ -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) diff --git a/src/update_gha/rewrite.py b/src/update_gha/rewrite.py index 5037f33..e3138c6 100644 --- a/src/update_gha/rewrite.py +++ b/src/update_gha/rewrite.py @@ -1,7 +1,9 @@ """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 @@ -9,6 +11,8 @@ 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.""" @@ -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 @@ -38,29 +64,54 @@ 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): @@ -68,6 +119,62 @@ def apply_version_updates( 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]] = {} diff --git a/src/update_gha/scan.py b/src/update_gha/scan.py index bb66a00..fd79cb7 100644 --- a/src/update_gha/scan.py +++ b/src/update_gha/scan.py @@ -30,6 +30,7 @@ UpdateVersionWith, ) from update_gha.rewrite import ( + PinRewrite, actions_from_spans, apply_version_updates, parse_uses_spans, @@ -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): @@ -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, @@ -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 (), ) @@ -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://")): diff --git a/tests/fixtures/sha_comment_expected.yml b/tests/fixtures/sha_comment_expected.yml new file mode 100644 index 0000000..f2ab63d --- /dev/null +++ b/tests/fixtures/sha_comment_expected.yml @@ -0,0 +1,23 @@ +name: SHA comment shapes +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: 'actions/setup-python@a1b2c3d4e5f60718293a4b5c6d7e8f9012345678' # v5.0.0 + - uses: "actions/cache@b2c3d4e5f60718293a4b5c6d7e8f90123456789a" # v4.0.2 + - uses: actions/setup-node@c3d4e5f60718293a4b5c6d7e8f90123456789ab # v4.1.0 + - uses: docker/login-action@d4e5f60718293a4b5c6d7e8f90123456789abc0 # v3.3.0 + - uses: actions/upload-artifact@e5f60718293a4b5c6d7e8f90123456789abcd01 # v4.3.1 + - uses: "actions/github-script@f60718293a4b5c6d7e8f90123456789abcde012" # v7.0.1 + - uses: actions/labeler@0123456789abcdef0123456789abcdef01234567 # keep my note + - uses: actions/stale@123456789abcdef0123456789abcdef012345678 # latest + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + - uses: owner/nested/action@23456789abcdef0123456789abcdef0123456789 # v1.4.0 + - uses: org/tool/.github/workflows/ci.yml@3456789abcdef0123456789abcdef01234567890 # v2.1.0 + - uses: ./local + - uses: docker://alpine:3 + - run: echo "actions/checkout@v3" diff --git a/tests/fixtures/sha_comment_input.yml b/tests/fixtures/sha_comment_input.yml new file mode 100644 index 0000000..b7a6d1d --- /dev/null +++ b/tests/fixtures/sha_comment_input.yml @@ -0,0 +1,23 @@ +name: SHA comment shapes +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: 'actions/setup-python@v4' + - uses: "actions/cache@v3" + - uses: actions/setup-node@v3 # v3.8.0 + - uses: docker/login-action@v3 # 3.0.0 + - uses: actions/upload-artifact@v3 # v3 + - uses: "actions/github-script@v6"# v6 + - uses: actions/labeler@v4 # keep my note + - uses: actions/stale@v8 # latest + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + - uses: owner/nested/action@v1 + - uses: org/tool/.github/workflows/ci.yml@v2 + - uses: ./local + - uses: docker://alpine:3 + - run: echo "actions/checkout@v3" diff --git a/tests/test_cli.py b/tests/test_cli.py index e133beb..9958866 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -81,6 +81,7 @@ def test_help() -> None: assert "Required for --pull-request and for private action repos" in output assert "Required with --pull-request" in output assert "release-tag (default) writes the latest stable tag" in output + assert "# tag comment" in output assert "Unlike --check, a pending update is not a failure" in output assert "update-gha[action]" in output assert "OWNER/REPO" in output diff --git a/tests/test_models.py b/tests/test_models.py index adaf41c..84c0a9d 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -50,6 +50,49 @@ def test_markdown_line_for_release_tag() -> None: ) +def test_markdown_line_for_release_commit_comment_only() -> None: + line = _update( + old_version="abc", + new_version="abc", + resolved=ResolvedVersion( + version="abc", + release=ReleaseInfo(tag_name="v2", html_url="r", published_at="p"), + commit=CommitInfo(sha="a", url="u", date="d"), + ), + update_version_with=UpdateVersionWith.LATEST_RELEASE_COMMIT_SHA, + ).markdown_line() + assert line == ( + "* **[o/r](https://github.com/o/r)** updated the release tag comment " + "to **[v2](r)**\n" + ) + + +def test_text_summary_for_comment_only_sha_pin() -> None: + action = _update( + location="actions/checkout", + old_version="abc", + new_version="abc", + resolved=ResolvedVersion( + version="abc", + release=ReleaseInfo(tag_name="v4.2.2", html_url="r", published_at="p"), + ), + update_version_with=UpdateVersionWith.LATEST_RELEASE_COMMIT_SHA, + ) + report = UpdateReport( + files=( + FileUpdate( + path=Path("ci.yml"), + original="old\n", + updated="new\n", + actions=(action,), + ), + ) + ) + assert report.text_summary() == ( + "actions/checkout abc (v4.2.2)\n\nUpdated 1 actions in 1 files." + ) + + def test_markdown_line_for_release_commit() -> None: line = _update( new_version="abc", diff --git a/tests/test_scan.py b/tests/test_scan.py index 0099c41..b3a57c1 100644 --- a/tests/test_scan.py +++ b/tests/test_scan.py @@ -16,12 +16,14 @@ from update_gha.config import Configuration from update_gha.github import GitHubAPIError, GitHubClient from update_gha.models import ( + CommitInfo, ReleaseInfo, ReleaseType, ResolvedVersion, UpdateVersionWith, ) from update_gha.scan import ( + _release_tag_comment, _split_action, _write_text_preserving_newlines, run_update, @@ -62,6 +64,9 @@ def resolve_new_version( ) +CHECKOUT_SHA = "11bd71901bbe5b1630ceea73d27597364c9af683" + + def _checkout_v4() -> ResolvedVersion: return ResolvedVersion( version="v4", @@ -73,6 +78,22 @@ def _checkout_v4() -> ResolvedVersion: ) +def _checkout_sha(tag: str = "v4.2.2") -> ResolvedVersion: + return ResolvedVersion( + version=CHECKOUT_SHA, + release=ReleaseInfo( + tag_name=tag, + html_url=f"https://github.com/actions/checkout/releases/tag/{tag}", + published_at="2024-01-01T00:00:00Z", + ), + commit=CommitInfo( + sha=CHECKOUT_SHA, + url=f"https://github.com/actions/checkout/commit/{CHECKOUT_SHA}", + date="2024-01-01T00:00:00Z", + ), + ) + + def test_writes_only_changed_files(tmp_path: Path) -> None: workflow = write_workflow(tmp_path, "ci.yml", CHECKOUT_V3) other = write_workflow( @@ -432,3 +453,208 @@ def read_bytes(self: Path) -> bytes: assert "actions/checkout@v3" in blocked.read_text(encoding="utf-8") assert "actions/checkout@v4" in good.read_text(encoding="utf-8") assert report.has_updates is True + + +def test_release_commit_sha_writes_tag_comment(tmp_path: Path) -> None: + workflow = write_workflow(tmp_path, "ci.yml", CHECKOUT_V3) + report = run_update( + Configuration( + write=True, + update_version_with=UpdateVersionWith.LATEST_RELEASE_COMMIT_SHA, + ), + reporter=silent_reporter(), + client=FakeLookup({("actions/checkout", "v3"): _checkout_sha()}), + workspace=tmp_path, + ) + assert workflow.read_text(encoding="utf-8") == ( + f"jobs:\n a:\n steps:\n - uses: actions/checkout@{CHECKOUT_SHA}" + " # v4.2.2\n" + ) + assert report.has_updates is True + assert report.action_updates[0].new_version == CHECKOUT_SHA + + +def test_release_commit_sha_updates_existing_tag_comment(tmp_path: Path) -> None: + workflow = write_workflow( + tmp_path, + "ci.yml", + "jobs:\n a:\n steps:\n - uses: actions/checkout@v3 # 0.3.1\n", + ) + run_update( + Configuration( + write=True, + update_version_with=UpdateVersionWith.LATEST_RELEASE_COMMIT_SHA, + ), + reporter=silent_reporter(), + client=FakeLookup({("actions/checkout", "v3"): _checkout_sha("1.0.0")}), + workspace=tmp_path, + ) + assert workflow.read_text(encoding="utf-8") == ( + f"jobs:\n a:\n steps:\n - uses: actions/checkout@{CHECKOUT_SHA}" + " # 1.0.0\n" + ) + + +def test_release_commit_sha_keeps_custom_comment(tmp_path: Path) -> None: + workflow = write_workflow( + tmp_path, + "ci.yml", + "jobs:\n a:\n steps:\n - uses: actions/checkout@v3 # keep me\n", + ) + run_update( + Configuration( + write=True, + update_version_with=UpdateVersionWith.LATEST_RELEASE_COMMIT_SHA, + ), + reporter=silent_reporter(), + client=FakeLookup({("actions/checkout", "v3"): _checkout_sha()}), + workspace=tmp_path, + ) + assert workflow.read_text(encoding="utf-8") == ( + f"jobs:\n a:\n steps:\n - uses: actions/checkout@{CHECKOUT_SHA}" + " # keep me\n" + ) + + +def test_release_tag_mode_does_not_add_comment(tmp_path: Path) -> None: + workflow = write_workflow(tmp_path, "ci.yml", CHECKOUT_V3) + run_update( + Configuration(write=True), + reporter=silent_reporter(), + client=FakeLookup({("actions/checkout", "v3"): _checkout_v4()}), + workspace=tmp_path, + ) + assert workflow.read_text(encoding="utf-8") == ( + "jobs:\n a:\n steps:\n - uses: actions/checkout@v4\n" + ) + + +def test_default_branch_sha_does_not_add_comment(tmp_path: Path) -> None: + workflow = write_workflow(tmp_path, "ci.yml", CHECKOUT_V3) + resolved = ResolvedVersion( + version=CHECKOUT_SHA, + commit=CommitInfo(sha=CHECKOUT_SHA, url="u", date="d"), + ) + run_update( + Configuration( + write=True, + update_version_with=UpdateVersionWith.DEFAULT_BRANCH_COMMIT_SHA, + ), + reporter=silent_reporter(), + client=FakeLookup({("actions/checkout", "v3"): resolved}), + workspace=tmp_path, + ) + assert workflow.read_text(encoding="utf-8") == ( + f"jobs:\n a:\n steps:\n - uses: actions/checkout@{CHECKOUT_SHA}\n" + ) + + +def test_stale_tag_comment_on_current_sha_is_replaced(tmp_path: Path) -> None: + workflow = write_workflow( + tmp_path, + "ci.yml", + f"jobs:\n a:\n steps:\n - uses: actions/checkout@{CHECKOUT_SHA}" + " # v3.5.0\n", + ) + report = run_update( + Configuration( + write=True, + update_version_with=UpdateVersionWith.LATEST_RELEASE_COMMIT_SHA, + ), + reporter=silent_reporter(), + client=FakeLookup( + {("actions/checkout", CHECKOUT_SHA): _checkout_sha("v4.2.2")} + ), + workspace=tmp_path, + ) + assert workflow.read_text(encoding="utf-8") == ( + f"jobs:\n a:\n steps:\n - uses: actions/checkout@{CHECKOUT_SHA}" + " # v4.2.2\n" + ) + assert report.has_updates is True + assert report.action_updates[0].old_version == CHECKOUT_SHA + assert report.action_updates[0].new_version == CHECKOUT_SHA + + +def test_current_sha_with_correct_comment_is_unchanged(tmp_path: Path) -> None: + original = ( + f"jobs:\n a:\n steps:\n - uses: actions/checkout@{CHECKOUT_SHA}" + " # v4.2.2\n" + ) + workflow = write_workflow(tmp_path, "ci.yml", original) + report = run_update( + Configuration( + write=True, + update_version_with=UpdateVersionWith.LATEST_RELEASE_COMMIT_SHA, + ), + reporter=silent_reporter(), + client=FakeLookup( + {("actions/checkout", CHECKOUT_SHA): _checkout_sha("v4.2.2")} + ), + workspace=tmp_path, + ) + assert workflow.read_text(encoding="utf-8") == original + assert report.has_updates is False + assert report.action_updates == () + + +def test_current_sha_without_comment_gets_tag_comment(tmp_path: Path) -> None: + workflow = write_workflow( + tmp_path, + "ci.yml", + f"jobs:\n a:\n steps:\n - uses: actions/checkout@{CHECKOUT_SHA}\n", + ) + report = run_update( + Configuration( + write=True, + update_version_with=UpdateVersionWith.LATEST_RELEASE_COMMIT_SHA, + ), + reporter=silent_reporter(), + client=FakeLookup( + {("actions/checkout", CHECKOUT_SHA): _checkout_sha("v4.2.2")} + ), + workspace=tmp_path, + ) + assert workflow.read_text(encoding="utf-8") == ( + f"jobs:\n a:\n steps:\n - uses: actions/checkout@{CHECKOUT_SHA}" + " # v4.2.2\n" + ) + assert report.has_updates is True + + +def test_release_tag_comment_helper() -> None: + sha = _checkout_sha() + assert ( + _release_tag_comment(UpdateVersionWith.LATEST_RELEASE_COMMIT_SHA, sha) + == "v4.2.2" + ) + assert _release_tag_comment(UpdateVersionWith.LATEST_RELEASE_TAG, sha) is None + assert ( + _release_tag_comment( + UpdateVersionWith.LATEST_RELEASE_COMMIT_SHA, + ResolvedVersion(version=CHECKOUT_SHA), + ) + is None + ) + assert ( + _release_tag_comment( + UpdateVersionWith.LATEST_RELEASE_COMMIT_SHA, + ResolvedVersion( + version=CHECKOUT_SHA, + release=ReleaseInfo(tag_name=" ", html_url="u", published_at="p"), + ), + ) + is None + ) + assert ( + _release_tag_comment( + UpdateVersionWith.LATEST_RELEASE_COMMIT_SHA, + ResolvedVersion( + version=CHECKOUT_SHA, + release=ReleaseInfo( + tag_name="v4\n.2.2", html_url="u", published_at="p" + ), + ), + ) + is None + ) diff --git a/tests/test_sha_comments.py b/tests/test_sha_comments.py new file mode 100644 index 0000000..7ae1385 --- /dev/null +++ b/tests/test_sha_comments.py @@ -0,0 +1,143 @@ +"""SHA-pin ``# tag`` comments.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from update_gha.rewrite import PinRewrite, apply_version_updates, get_all_actions + +FIXTURES = Path(__file__).parent / "fixtures" + +CHECKOUT_SHA = "11bd71901bbe5b1630ceea73d27597364c9af683" +PYTHON_SHA = "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678" +CACHE_SHA = "b2c3d4e5f60718293a4b5c6d7e8f90123456789a" +NODE_SHA = "c3d4e5f60718293a4b5c6d7e8f90123456789ab" +LOGIN_SHA = "d4e5f60718293a4b5c6d7e8f90123456789abc0" +UPLOAD_SHA = "e5f60718293a4b5c6d7e8f90123456789abcd01" +SCRIPT_SHA = "f60718293a4b5c6d7e8f90123456789abcde012" +LABELER_SHA = "0123456789abcdef0123456789abcdef01234567" +STALE_SHA = "123456789abcdef0123456789abcdef012345678" +NESTED_SHA = "23456789abcdef0123456789abcdef0123456789" +REUSABLE_SHA = "3456789abcdef0123456789abcdef01234567890" +OLD_SHA = "8f4b7f84864484a7ac97850a33f01d618c579def" + +SHA_UPDATES: dict[str, PinRewrite] = { + "actions/checkout@v2": PinRewrite(CHECKOUT_SHA, "v4.2.2"), + "actions/checkout@v3": PinRewrite(CHECKOUT_SHA, "v4.2.2"), + "actions/setup-python@v4": PinRewrite(PYTHON_SHA, "v5.0.0"), + "actions/cache@v3": PinRewrite(CACHE_SHA, "v4.0.2"), + "actions/setup-node@v3": PinRewrite(NODE_SHA, "v4.1.0"), + "docker/login-action@v3": PinRewrite(LOGIN_SHA, "v3.3.0"), + "actions/upload-artifact@v3": PinRewrite(UPLOAD_SHA, "v4.3.1"), + "actions/github-script@v6": PinRewrite(SCRIPT_SHA, "v7.0.1"), + "actions/labeler@v4": PinRewrite(LABELER_SHA, "v5.0.0"), + "actions/stale@v8": PinRewrite(STALE_SHA, "v9.0.0"), + "owner/nested/action@v1": PinRewrite(NESTED_SHA, "v1.4.0"), + "org/tool/.github/workflows/ci.yml@v2": PinRewrite(REUSABLE_SHA, "v2.1.0"), +} + + +def _pin(text: str, tag: str = "v4.2.2") -> str: + return apply_version_updates( + text, {"actions/checkout@v3": PinRewrite(CHECKOUT_SHA, tag)} + ) + + +def test_sha_comment_fixture() -> None: + input_text = (FIXTURES / "sha_comment_input.yml").read_text(encoding="utf-8") + expected = (FIXTURES / "sha_comment_expected.yml").read_text(encoding="utf-8") + actions = get_all_actions(input_text) or frozenset() + updates = { + action: SHA_UPDATES[action] for action in actions if action in SHA_UPDATES + } + assert apply_version_updates(input_text, updates) == expected + + +@pytest.mark.parametrize("spaces", [1, 2, 8]) +def test_previous_version_comment_is_replaced(spaces: int) -> None: + source = f"steps:\n - uses: actions/checkout@{OLD_SHA}{' ' * spaces}# v3.5.0\n" + updated = apply_version_updates( + source, {f"actions/checkout@{OLD_SHA}": PinRewrite(CHECKOUT_SHA, "v4.2.2")} + ) + assert updated == f"steps:\n - uses: actions/checkout@{CHECKOUT_SHA} # v4.2.2\n" + + +def test_quoted_pin_gets_two_space_comment() -> None: + assert _pin("steps:\n - uses: 'actions/checkout@v3'\n") == ( + f"steps:\n - uses: 'actions/checkout@{CHECKOUT_SHA}' # v4.2.2\n" + ) + assert _pin('steps:\n - uses: "actions/checkout@v3"# v3\n') == ( + f'steps:\n - uses: "actions/checkout@{CHECKOUT_SHA}" # v4.2.2\n' + ) + + +def test_custom_comment_is_left_alone() -> None: + assert _pin("steps:\n - uses: actions/checkout@v3 # keep me\n") == ( + f"steps:\n - uses: actions/checkout@{CHECKOUT_SHA} # keep me\n" + ) + assert _pin("steps:\n - uses: actions/checkout@v3 #\n") == ( + f"steps:\n - uses: actions/checkout@{CHECKOUT_SHA} #\n" + ) + + +def test_existing_tag_comment_is_normalized() -> None: + text = f"steps:\n - uses: actions/checkout@{CHECKOUT_SHA} # v4.2.2\n" + updated = apply_version_updates( + text, {f"actions/checkout@{CHECKOUT_SHA}": PinRewrite(CHECKOUT_SHA, "v4.2.2")} + ) + assert updated == f"steps:\n - uses: actions/checkout@{CHECKOUT_SHA} # v4.2.2\n" + already = f"steps:\n - uses: actions/checkout@{CHECKOUT_SHA} # v4.2.2\n" + assert ( + apply_version_updates( + already, + {f"actions/checkout@{CHECKOUT_SHA}": PinRewrite(CHECKOUT_SHA, "v4.2.2")}, + ) + == already + ) + + +@pytest.mark.parametrize( + "text", + [ + "steps: [{uses: actions/checkout@v3}]\n", + "steps:\n - uses: >-\n actions/checkout@v3\n", + 'steps:\n - uses: "actions/checkout@\\\nv3"\n', + ], +) +def test_flow_and_block_scalars_are_not_annotated(text: str) -> None: + updated = apply_version_updates( + text, {"actions/checkout@v3": PinRewrite(CHECKOUT_SHA, "v4.2.2")} + ) + assert " # v4.2.2" not in updated + + +def test_crlf_and_missing_newline() -> None: + crlf = "steps:\r\n - uses: actions/checkout@v3\r\n" + assert f"@{CHECKOUT_SHA} # v4.2.2\r\n" in _pin(crlf) + assert _pin("steps:\n - uses: actions/checkout@v3") == ( + f"steps:\n - uses: actions/checkout@{CHECKOUT_SHA} # v4.2.2" + ) + + +def test_multiline_quoted_value_is_not_annotated() -> None: + text = 'steps:\n - uses: "actions/checkout@v3\n"\n' + updated = apply_version_updates( + text, {"actions/checkout@v3 ": PinRewrite("v3 ", "v4.2.2")} + ) + assert updated == text + + +def test_unsafe_tag_and_plain_string_update() -> None: + text = "steps:\n - uses: actions/checkout@v3\n" + for tag in ("v4\n.2", "v4\r.2", " "): + assert ( + apply_version_updates( + text, {"actions/checkout@v3": PinRewrite(CHECKOUT_SHA, tag)} + ) + == f"steps:\n - uses: actions/checkout@{CHECKOUT_SHA}\n" + ) + assert apply_version_updates(text, {"actions/checkout@v3": "v4"}) == ( + "steps:\n - uses: actions/checkout@v4\n" + )