diff --git a/repo_policy_sync/docs/explanation/architecture.md b/repo_policy_sync/docs/explanation/architecture.md index 72267fa..11e3f9e 100644 --- a/repo_policy_sync/docs/explanation/architecture.md +++ b/repo_policy_sync/docs/explanation/architecture.md @@ -55,6 +55,15 @@ references; operation handlers do not know where those values came from. `operations/`. An operation owns its YAML validation, compliance check, remediation description, and application. +GitHub target ref operations scan workflow text directly so YAML comments and +formatting survive updates. `GitHubResolver` is created once for a run, uses +the authenticated `gh api` boundary for repository tag and commit metadata, +and shares a synchronized in-memory cache across policy and repository +workers. Exact refs do not require remote resolution; minimal refs resolve +tags and use GitHub's commit ancestry comparison for full SHA pins. The +workflow target can be an action path or an external reusable-workflow path; +the API repository is derived from its first two path components. + The registry is intentionally built in. It makes supported operations visible and testable without runtime discovery, third-party code loading, or an extension ABI. Add a new operation by implementing it in `operations/` and diff --git a/repo_policy_sync/docs/reference/policy-format.md b/repo_policy_sync/docs/reference/policy-format.md index 19ac107..0c94122 100644 --- a/repo_policy_sync/docs/reference/policy-format.md +++ b/repo_policy_sync/docs/reference/policy-format.md @@ -181,6 +181,42 @@ existing match; without one, it appends the line. A missing file is created. Globs use `*`, `?`, and `[...]` and match complete raw lines; comments and whitespace have no special meaning. +### `ensure_exact` + +```yaml +- type: ensure_exact + target: actions/checkout + ref: 11bd719055c2f7a3f9e0f8c1e4f2a5b6c7d8e9f0 +``` + +Updates every matching external action or reusable-workflow `uses:` reference +in workflow files under `.github/workflows/`. `target` uses the complete +external target before `@`, such as `actions/checkout`, +`eclipse-score/cicd-actions/setup-bazel-cache`, or +`eclipse-score/cicd-actions/.github/workflows/build.yml`. `ref` may be a +branch, tag, or complete 40-character commit SHA. The operation preserves +surrounding YAML formatting, quotes, and comments. Local actions and local +reusable workflows are outside this operation. + +### `ensure_minimal` + +```yaml +- type: ensure_minimal + target: actions/setup-python + minimum_version: v5.1 +``` + +The operation obtains the target repository's tags through the authenticated +`gh api` client and selects the lowest semantic tag that satisfies +`minimum_version`. Older semantic tags are replaced with that tag. A complete +SHA is compared with the resolved tag commit through GitHub's commit comparison +API; an ancestor is updated to the resolved commit SHA, while an identical or +newer commit is left alone. Diverged commit histories are rejected because +their order is not unambiguous. Branches and other non-semantic refs are +intentionally unchanged. Repository tags and commit comparisons are cached for +the duration of one policy run. A policy can contain any number of +`ensure_exact` and `ensure_minimal` entries for actions or reusable workflows. + ### `remove_file` ```yaml diff --git a/repo_policy_sync/policies/README.md b/repo_policy_sync/policies/README.md index 95e4707..131b1be 100644 --- a/repo_policy_sync/policies/README.md +++ b/repo_policy_sync/policies/README.md @@ -32,6 +32,7 @@ use the [documentation index](../docs/README.md). | `docs-as-code-gitignore` | Update `score_docs_as_code` Git ignore entries and remove legacy configuration files. | One-time cleanup | | `minimal-bazel-module-declaration` | Keep `MODULE.bazel` limited to the repository-owned module name by removing version metadata. | One-time cleanup | | `minimum-bazel-version` | Upgrade repositories to at least Bazel `8.6.0` and regenerate the lockfile when required. | Baseline maintenance | +| `minimum-github-action-versions` | Keep `actions/checkout` at semantic version `v6` or newer in GitHub workflows. | Baseline maintenance | | `score-devcontainer-dependency-alignment` | Declare the direct SCORE devcontainer Bazel dependency using the Dockerfile image version. | One-time integration | The policy definitions and their executable before/after cases are the diff --git a/repo_policy_sync/policies/minimum-github-action-versions/policy.yml b/repo_policy_sync/policies/minimum-github-action-versions/policy.yml new file mode 100644 index 0000000..4bda633 --- /dev/null +++ b/repo_policy_sync/policies/minimum-github-action-versions/policy.yml @@ -0,0 +1,20 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +title: "chore(actions): keep actions/checkout at v6 or newer" +description: Ensure workflows use a supported release of the standard checkout action. + +ensure: + - type: ensure_minimal + target: actions/checkout + minimum_version: v6 + rationale: Keep the checkout action on v6 or a newer semantic release. diff --git a/repo_policy_sync/src/engine.py b/repo_policy_sync/src/engine.py index 8a7673d..07556de 100644 --- a/repo_policy_sync/src/engine.py +++ b/repo_policy_sync/src/engine.py @@ -30,6 +30,7 @@ starlark_call_ranges, ) from .errors import CommandError, RepoPolicySyncError, redact_sensitive_text +from .github import GitHubResolver from .models import Change, EnsureOperation, Evaluation, Policy from .operations import apply as apply_operation from .operations import describe_changes, resolve_operation @@ -52,18 +53,29 @@ def evaluate_policy( - root: Path, policy: Policy, *, organization: str | None = None + root: Path, + policy: Policy, + *, + organization: str | None = None, + github_resolver: GitHubResolver | None = None, ) -> Evaluation: """Evaluate a policy against a checked-out repository without changing it.""" evaluation, _ = _evaluate_policy_with_operations( - root, policy, organization=organization + root, + policy, + organization=organization, + github_resolver=github_resolver, ) return evaluation def _evaluate_policy_with_operations( - root: Path, policy: Policy, *, organization: str | None = None + root: Path, + policy: Policy, + *, + organization: str | None = None, + github_resolver: GitHubResolver | None = None, ) -> tuple[Evaluation, tuple[EnsureOperation, ...]]: """Evaluate a policy and retain the materialized operations for applying it.""" @@ -75,9 +87,17 @@ def _evaluate_policy_with_operations( operations = tuple( resolve_operation(operation, values) for operation in policy.ensure ) + github_resolver = github_resolver or GitHubResolver() changes: list[Change] = [] for operation in operations: - changes.extend(describe_changes(root, operation, organization=organization)) + changes.extend( + describe_changes( + root, + operation, + organization=organization, + github_resolver=github_resolver, + ) + ) if changes: changes.extend( Change(command.when_file_exists, command.description) @@ -95,16 +115,26 @@ def apply_policy( *, force_after_apply: bool = False, organization: str | None = None, + github_resolver: GitHubResolver | None = None, ) -> Evaluation: """Apply a matching policy and return the changes that were made.""" + github_resolver = github_resolver or GitHubResolver() evaluation, operations = _evaluate_policy_with_operations( - root, policy, organization=organization + root, + policy, + organization=organization, + github_resolver=github_resolver, ) if not evaluation.applies: return evaluation for operation in operations: - apply_operation(root, operation, organization=organization) + apply_operation( + root, + operation, + organization=organization, + github_resolver=github_resolver, + ) if evaluation.changes or force_after_apply: changed_paths = {change.path for change in evaluation.changes} for command in policy.after_apply: diff --git a/repo_policy_sync/src/github.py b/repo_policy_sync/src/github.py index 5376c77..7060c4c 100644 --- a/repo_policy_sync/src/github.py +++ b/repo_policy_sync/src/github.py @@ -23,6 +23,7 @@ from dataclasses import dataclass from importlib.resources import files from pathlib import Path +from threading import RLock from .errors import CommandError, RepoPolicySyncError, redact_sensitive_text from .models import Change, Policy, policy_branch_slug @@ -847,6 +848,168 @@ def _run( return result.stdout +@dataclass(frozen=True) +class GitHubTag: + """A release tag and the commit GitHub resolves it to. + + Repository identity is intentionally kept by the resolver that returned + the tag. A tag result is always scoped to one repository, so this value + object can describe the release without duplicating that context. + """ + + name: str + sha: str + + +class GitHubResolver: + """Resolve GitHub repository releases through authenticated ``gh api`` calls. + + Repository metadata is immutable for the duration of a policy run. Keeping + the cache on this object avoids repeated requests when a policy has several + matching workflow entries or several targets from the same repository. The + lock also prevents concurrent repository workers from fetching the same + metadata twice. + """ + + def __init__(self, client: GitHubCli | None = None) -> None: + self._client = client or GitHubCli() + self._tags: dict[str, tuple[GitHubTag, ...]] = {} + self._comparisons: dict[tuple[str, str, str], str] = {} + self._lock = RLock() + + def tags(self, repository: str) -> tuple[GitHubTag, ...]: + """Return all published tags for one ``owner/repository``. + + The result is scoped to the requested repository. Keeping that scope at + the resolver boundary ensures callers cannot accidentally select a tag + from a different repository while keeping ``GitHubTag`` repository + agnostic. + """ + + with self._lock: + cached = self._tags.get(repository) + if cached is not None: + return cached + output = self._client._run( + [ + "gh", + "api", + "--paginate", + "--slurp", + f"/repos/{repository}/tags?per_page=100", + ] + ) + tags = _parse_repository_tags(output, repository) + self._tags[repository] = tags + return tags + + def compare_commits(self, repository: str, base: str, head: str) -> str: + """Return GitHub's ancestry relationship for ``base`` and ``head``. + + GitHub reports ``behind`` when ``head`` is an ancestor of ``base``, + which is exactly the state in which a target pin needs updating. + ``diverged`` is intentionally retained as an error by the operation: + commit timestamps cannot safely turn unrelated histories into a + minimum-version decision. + """ + + key = (repository, base, head) + with self._lock: + cached = self._comparisons.get(key) + if cached is not None: + return cached + output = self._client._run( + [ + "gh", + "api", + f"/repos/{repository}/compare/{base}...{head}", + ] + ) + try: + response = json.loads(output) + except json.JSONDecodeError as exc: + raise CommandError( + f"gh returned invalid commit comparison JSON for {repository}" + ) from exc + if not isinstance(response, dict) or not isinstance( + response.get("status"), str + ): + raise CommandError( + f"gh returned invalid commit comparison JSON for {repository}" + ) + status = response["status"] + if status not in {"ahead", "behind", "identical", "diverged"}: + raise CommandError( + f"gh returned an unknown commit comparison status for {repository}: {status!r}" + ) + self._comparisons[key] = status + return status + + +def _parse_repository_tags(output: str, repository: str) -> tuple[GitHubTag, ...]: + """Validate the paginated shape returned by GitHub's tags endpoint.""" + + try: + pages = json.loads(output) + except json.JSONDecodeError as exc: + raise CommandError( + f"gh returned invalid repository tag JSON for {repository}" + ) from exc + # `gh api --paginate --slurp` must produce a list. Treating any other + # shape as an error avoids silently interpreting an API error object as a + # repository with no usable releases. + if not isinstance(pages, list): + raise CommandError(f"gh returned invalid repository tag JSON for {repository}") + # The normal `--slurp` shape is a list of page lists, while lightweight API + # doubles and non-paginated callers commonly provide one flat entry list. + # Normalize both forms here so release selection never depends on how the + # response was paginated. + entries = ( + [entry for page in pages for entry in page] + if all(isinstance(page, list) for page in pages) + else pages + ) + tags: list[GitHubTag] = [] + for entry in entries: + # A malformed item must fail the policy rather than being skipped and + # potentially making an incomplete release list look authoritative. + if not isinstance(entry, dict): + raise CommandError( + f"gh returned invalid repository tag JSON for {repository}" + ) + name = entry.get("name") + commit = entry.get("commit") + # The tags endpoint normally exposes `name` and `commit`, but GitHub's + # ref-shaped representation uses `ref` and `object`. Accept both so + # endpoint representation details cannot change the resolved release. + if name is None: + reference = entry.get("ref") + object_value = entry.get("object") + if ( + isinstance(reference, str) + and reference.startswith("refs/tags/") + and isinstance(object_value, dict) + ): + name = reference.removeprefix("refs/tags/") + commit = object_value + sha = commit.get("sha") if isinstance(commit, dict) else None + # The policy compares semantic tag names and pins to commit SHAs. Both + # values therefore need to be validated before they influence either + # decision; malformed metadata is safer as an error than as a partial + # or incorrect update. + if ( + not isinstance(name, str) + or not name + or not isinstance(sha, str) + or re.fullmatch(r"[0-9a-fA-F]{40}", sha) is None + ): + raise CommandError( + f"gh returned invalid repository tag JSON for {repository}" + ) + tags.append(GitHubTag(name, sha)) + return tuple(tags) + + def policy_branch(policy_id: str) -> str: """Map a stable policy identifier to a safe, deterministic branch name.""" diff --git a/repo_policy_sync/src/models.py b/repo_policy_sync/src/models.py index 15f7eff..316b917 100644 --- a/repo_policy_sync/src/models.py +++ b/repo_policy_sync/src/models.py @@ -101,6 +101,24 @@ class EnsureMinimumVersion: rationale: str | None = None +@dataclass(frozen=True) +class EnsureExactGitHubRef: + """Ensure every use of one external GitHub target points at one ref.""" + + target: str + ref: str + rationale: str | None = None + + +@dataclass(frozen=True) +class EnsureMinimalGitHubRef: + """Ensure GitHub target tags and commit pins meet a minimum release.""" + + target: str + minimum_version: str + rationale: str | None = None + + @dataclass(frozen=True) class ValueReference: """A reference to a named value derived by the containing policy.""" @@ -155,6 +173,8 @@ class EnsureBazelDependencyDevDependency: | RemoveFile | ReplaceRegex | EnsureMinimumVersion + | EnsureExactGitHubRef + | EnsureMinimalGitHubRef | EnsureBazelDependency | EnsureBazelDependencyDevDependency ) diff --git a/repo_policy_sync/src/operations/README.md b/repo_policy_sync/src/operations/README.md index 8ce50bf..973571f 100644 --- a/repo_policy_sync/src/operations/README.md +++ b/repo_policy_sync/src/operations/README.md @@ -29,6 +29,8 @@ authoritative source for the complete schema, validation rules, and examples. | `ensure_bazel_dependency` | Declaring a direct bzlmod dependency | Adds a `bazel_dep` with the configured module name and version when it is missing. | [`test_ensure_bazel_dependency.py`](../../tests/operations/test_ensure_bazel_dependency.py) | | `ensure_bazel_dependency_dev_dependency` | Controlling whether a direct bzlmod dependency is development-only | Adds or changes `dev_dependency = True`, or removes the attribute when configured as false, in the repository-root `MODULE.bazel`. | [`test_ensure_bazel_dependency_dev_dependency.py`](../../tests/operations/test_ensure_bazel_dependency_dev_dependency.py) | | `ensure_line` | Keeping one exact line in a text file | Inserts the desired line, removes configured replacements and duplicates, and creates a missing file. | [`test_ensure_line.py`](../../tests/operations/test_ensure_line.py) | +| `ensure_exact` | Pinning one external GitHub target to one exact ref | Updates every matching action or reusable-workflow `uses: target@ref` in `.github/workflows/**/*.yml` and `.yaml` to the configured branch, tag, or full commit SHA. | [`test_ensure_github_ref.py`](../../tests/operations/test_ensure_github_ref.py) | +| `ensure_minimal` | Maintaining a minimum release for one external GitHub target | Resolves the lowest available semantic tag meeting the configured minimum, upgrades older tags and stale commit pins, and leaves branches unchanged. | [`test_ensure_github_ref.py`](../../tests/operations/test_ensure_github_ref.py) | | `ensure_minimum_version` | Maintaining a simple version file such as `.bazelversion` | Replaces a lower `major.minor.patch` value; equal or higher versions and missing files are compliant. | [`test_ensure_minimum_version.py`](../../tests/operations/test_ensure_minimum_version.py) | | `remove_file` | Removing an obsolete file | Deletes an existing file; a missing file is compliant and directories are rejected. | [`test_remove_file.py`](../../tests/operations/test_remove_file.py) | | `replace_regex` | Applying a narrow text substitution | Applies Python `re.sub` to a complete UTF-8 file; missing files and non-matching patterns are compliant. | [`test_replace_regex.py`](../../tests/operations/test_replace_regex.py) | diff --git a/repo_policy_sync/src/operations/__init__.py b/repo_policy_sync/src/operations/__init__.py index 1f51ba7..01f367e 100644 --- a/repo_policy_sync/src/operations/__init__.py +++ b/repo_policy_sync/src/operations/__init__.py @@ -21,12 +21,17 @@ from typing import Any, Protocol from ..errors import RepoPolicySyncError +from ..github import GitHubResolver from ..models import Change, EnsureOperation, ValueReference from .ensure_bazel_dependency import EnsureBazelDependencyOperation from .ensure_bazel_dependency_dev_dependency import ( EnsureBazelDependencyDevDependencyOperation, ) from .ensure_line import EnsureLineOperation +from .ensure_github_ref import ( + EnsureExactGitHubRefOperation, + EnsureMinimalGitHubRefOperation, +) from .ensure_minimum_version import EnsureMinimumVersionOperation from .remove_file import RemoveFileOperation from .replace_regex import ReplaceRegexOperation @@ -44,6 +49,7 @@ def describe_changes( operation: EnsureOperation, *, organization: str | None = None, + github_resolver: GitHubResolver | None = None, ) -> tuple[Change, ...]: ... def apply( @@ -52,6 +58,7 @@ def apply( operation: EnsureOperation, *, organization: str | None = None, + github_resolver: GitHubResolver | None = None, ) -> None: ... @@ -59,6 +66,8 @@ def apply( EnsureBazelDependencyOperation(), EnsureBazelDependencyDevDependencyOperation(), EnsureLineOperation(), + EnsureExactGitHubRefOperation(), + EnsureMinimalGitHubRefOperation(), EnsureMinimumVersionOperation(), RemoveFileOperation(), ReplaceRegexOperation(), @@ -86,21 +95,37 @@ def parse_operation(raw: object, source: Path) -> EnsureOperation: def describe_changes( - root: Path, operation: EnsureOperation, *, organization: str | None = None + root: Path, + operation: EnsureOperation, + *, + organization: str | None = None, + github_resolver: GitHubResolver | None = None, ) -> tuple[Change, ...]: """Describe every path an operation would change.""" return _handler_for(operation).describe_changes( - root, operation, organization=organization + root, + operation, + organization=organization, + github_resolver=github_resolver, ) def apply( - root: Path, operation: EnsureOperation, *, organization: str | None = None + root: Path, + operation: EnsureOperation, + *, + organization: str | None = None, + github_resolver: GitHubResolver | None = None, ) -> None: """Apply one operation from a repository root.""" - _handler_for(operation).apply(root, operation, organization=organization) + _handler_for(operation).apply( + root, + operation, + organization=organization, + github_resolver=github_resolver, + ) def resolve_operation( diff --git a/repo_policy_sync/src/operations/ensure_bazel_dependency.py b/repo_policy_sync/src/operations/ensure_bazel_dependency.py index d7e4afe..7698ab1 100644 --- a/repo_policy_sync/src/operations/ensure_bazel_dependency.py +++ b/repo_policy_sync/src/operations/ensure_bazel_dependency.py @@ -77,6 +77,7 @@ def describe_changes( operation: EnsureOperation, *, organization: str | None = None, + github_resolver=None, ) -> tuple[Change, ...]: assert isinstance(operation, EnsureBazelDependency) dependency = _module_dependency(root, operation) @@ -97,6 +98,7 @@ def apply( operation: EnsureOperation, *, organization: str | None = None, + github_resolver=None, ) -> None: assert isinstance(operation, EnsureBazelDependency) if _module_dependency(root, operation) is not None: diff --git a/repo_policy_sync/src/operations/ensure_bazel_dependency_dev_dependency.py b/repo_policy_sync/src/operations/ensure_bazel_dependency_dev_dependency.py index b8f172d..7c3f069 100644 --- a/repo_policy_sync/src/operations/ensure_bazel_dependency_dev_dependency.py +++ b/repo_policy_sync/src/operations/ensure_bazel_dependency_dev_dependency.py @@ -87,6 +87,7 @@ def describe_changes( operation: EnsureOperation, *, organization: str | None = None, + github_resolver=None, ) -> tuple[Change, ...]: assert isinstance(operation, EnsureBazelDependencyDevDependency) _, dependency = _find_dependency(root, operation) @@ -108,6 +109,7 @@ def apply( operation: EnsureOperation, *, organization: str | None = None, + github_resolver=None, ) -> None: assert isinstance(operation, EnsureBazelDependencyDevDependency) path = root / _MODULE_FILE diff --git a/repo_policy_sync/src/operations/ensure_github_ref.py b/repo_policy_sync/src/operations/ensure_github_ref.py new file mode 100644 index 0000000..ba9ccc5 --- /dev/null +++ b/repo_policy_sync/src/operations/ensure_github_ref.py @@ -0,0 +1,500 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +"""Operations for maintaining external GitHub refs used in workflows.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from functools import cmp_to_key +from pathlib import Path +from typing import Any, Callable + +from ..errors import PolicyError, RepoPolicySyncError +from ..github import GitHubResolver, GitHubTag +from ..models import ( + Change, + EnsureExactGitHubRef, + EnsureMinimalGitHubRef, + EnsureOperation, +) +from ._validation import ( + expect_keys, + optional_string, + required_string, + validate_repository_path, +) + +_TARGET = re.compile( + r"[A-Za-z0-9_-][A-Za-z0-9_.-]*/[A-Za-z0-9_.-]+" + r"(?:/[A-Za-z0-9_.-]+)*\Z" +) +_FULL_SHA = re.compile(r"[0-9a-fA-F]{40}\Z") +_SEMVER = re.compile( + r"v?(?P0|[1-9][0-9]*)" + r"(?:\.(?P0|[1-9][0-9]*))?" + r"(?:\.(?P0|[1-9][0-9]*))?" + r"(?:-(?P[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?" + r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?\Z" +) +_USES_REFERENCE = re.compile( + r"(?m)^[ \t]*(?:-[ \t]+)?uses[ \t]*:[ \t]*" + r"(?P[\"']?)" + r"(?P[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)*)@" + r"(?P[^ \t\r\n#\"']+)" + r"(?P=quote)[ \t]*(?:#.*)?(?=\r?$)" +) +_BLOCK_SCALAR_HEADER = re.compile( + r"^[ \t]*.*:\s*[|>][+-]?[0-9]*\s*(?:#.*)?(?:\r?\n)?\Z" +) + + +@dataclass(frozen=True) +class _SemanticVersion: + major: int + minor: int + patch: int + prerelease: tuple[str, ...] = () + + +@dataclass(frozen=True) +class _WorkflowReference: + """One syntactically supported workflow ``uses:`` reference.""" + + ref: str + start: int + end: int + + +class EnsureExactGitHubRefOperation: + """Set all workflow references to one external target to one exact ref.""" + + operation_type = "ensure_exact" + operation_class = EnsureExactGitHubRef + + def parse(self, raw: dict[str, Any], source: Path) -> EnsureExactGitHubRef: + expect_keys(raw, {"type", "target", "ref", "rationale"}, source) + target = _parse_target(raw, source) + ref = required_string(raw, "ref", source) + if any(character.isspace() or character in "\"'#" for character in ref): + raise PolicyError( + f"policy {source}: ref must be a non-empty branch, tag, or full commit SHA" + ) + return EnsureExactGitHubRef( + target=target, + ref=ref, + rationale=optional_string(raw, "rationale", source), + ) + + def describe_changes( + self, + root: Path, + operation: EnsureOperation, + *, + organization: str | None = None, + github_resolver: GitHubResolver | None = None, + ) -> tuple[Change, ...]: + assert isinstance(operation, EnsureExactGitHubRef) + return _describe_workflow_updates( + root, + target=operation.target, + rationale=operation.rationale, + replacement_for=lambda _ref: operation.ref, + description=lambda count: ( + f"set {count} {operation.target!r} GitHub target reference(s) " + f"to {operation.ref!r}" + ), + ) + + def apply( + self, + root: Path, + operation: EnsureOperation, + *, + organization: str | None = None, + github_resolver: GitHubResolver | None = None, + ) -> None: + assert isinstance(operation, EnsureExactGitHubRef) + _apply_workflow_updates( + root, + target=operation.target, + replacement_for=lambda _ref: operation.ref, + ) + + +class EnsureMinimalGitHubRefOperation: + """Keep tags and commit pins at least as new as a configured release.""" + + operation_type = "ensure_minimal" + operation_class = EnsureMinimalGitHubRef + + def parse(self, raw: dict[str, Any], source: Path) -> EnsureMinimalGitHubRef: + expect_keys(raw, {"type", "target", "minimum_version", "rationale"}, source) + target = _parse_target(raw, source) + minimum_version = required_string(raw, "minimum_version", source) + if _parse_semantic_version(minimum_version) is None: + raise PolicyError( + f"policy {source}: minimum_version must be a semantic version such as v5.1" + ) + return EnsureMinimalGitHubRef( + target=target, + minimum_version=minimum_version, + rationale=optional_string(raw, "rationale", source), + ) + + def describe_changes( + self, + root: Path, + operation: EnsureOperation, + *, + organization: str | None = None, + github_resolver: GitHubResolver | None = None, + ) -> tuple[Change, ...]: + assert isinstance(operation, EnsureMinimalGitHubRef) + references = _workflow_references(root, operation.target) + if not references or all( + _parse_semantic_version(reference.ref) is None + and _FULL_SHA.fullmatch(reference.ref) is None + for reference in references + ): + return () + resolver = github_resolver or GitHubResolver() + required_tag = _resolve_minimum_tag(resolver, operation) + return _describe_workflow_updates( + root, + target=operation.target, + rationale=operation.rationale, + replacement_for=_minimal_replacement_for(operation, required_tag, resolver), + description=lambda count: ( + f"update {count} {operation.target!r} GitHub target reference(s) " + f"to minimum release {required_tag.name!r}" + ), + ) + + def apply( + self, + root: Path, + operation: EnsureOperation, + *, + organization: str | None = None, + github_resolver: GitHubResolver | None = None, + ) -> None: + assert isinstance(operation, EnsureMinimalGitHubRef) + references = _workflow_references(root, operation.target) + if not references or all( + _parse_semantic_version(reference.ref) is None + and _FULL_SHA.fullmatch(reference.ref) is None + for reference in references + ): + return + resolver = github_resolver or GitHubResolver() + required_tag = _resolve_minimum_tag(resolver, operation) + _apply_workflow_updates( + root, + target=operation.target, + replacement_for=_minimal_replacement_for(operation, required_tag, resolver), + ) + + +def _parse_target(raw: dict[str, Any], source: Path) -> str: + target = required_string(raw, "target", source) + if _TARGET.fullmatch(target) is None: + raise PolicyError( + f"policy {source}: target must be an external GitHub uses target in " + "owner/repository[/path] form; local targets are not supported" + ) + return target + + +def _repository_for(target: str) -> str: + """Extract the API repository from an action or reusable-workflow target. + + Workflow targets may append an action directory or workflow file to the + repository name, but GitHub's tag and comparison endpoints are scoped to + ``owner/repository``. The remaining path is used only for workflow text + matching and must not be sent to those endpoints. + """ + + return "/".join(target.split("/", 2)[:2]) + + +def _resolve_minimum_tag( + resolver: GitHubResolver, operation: EnsureMinimalGitHubRef +) -> GitHubTag: + minimum = _parse_semantic_version(operation.minimum_version) + assert minimum is not None + candidates = [ + (tag, _parse_semantic_version(tag.name)) + for tag in resolver.tags(_repository_for(operation.target)) + ] + candidates = [ + (tag, version) + for tag, version in candidates + if version is not None and _compare_versions(version, minimum) >= 0 + ] + if not candidates: + raise RepoPolicySyncError( + f"GitHub target {operation.target!r} has no tag meeting minimum version " + f"{operation.minimum_version!r}" + ) + + # Prefer the spelling requested by the policy when it exists. Otherwise + # choose the lowest available release so a policy does not jump farther + # than necessary; the name is a deterministic tie-breaker for equivalent + # forms such as v5.1 and v5.1.0. + exact_name = next( + (tag for tag, _version in candidates if tag.name == operation.minimum_version), + None, + ) + if exact_name is not None: + return exact_name + return min( + candidates, + key=cmp_to_key(_compare_tag_candidates), + )[0] + + +def _minimal_replacement_for( + operation: EnsureMinimalGitHubRef, + required_tag: GitHubTag, + resolver: GitHubResolver, +) -> Callable[[str], str]: + required_version = _parse_semantic_version(required_tag.name) + assert required_version is not None + + def replacement(current_ref: str) -> str: + current_version = _parse_semantic_version(current_ref) + if current_version is not None: + return ( + required_tag.name + if _compare_versions(current_version, required_version) < 0 + else current_ref + ) + if _FULL_SHA.fullmatch(current_ref) is None: + # A branch is intentionally not interpreted as a version. This + # also leaves expressions and other unsupported refs untouched. + return current_ref + if current_ref.lower() == required_tag.sha.lower(): + return current_ref + status = resolver.compare_commits( + _repository_for(operation.target), required_tag.sha, current_ref + ) + if status == "behind": + return required_tag.sha + if status == "diverged": + raise RepoPolicySyncError( + f"cannot compare GitHub target {operation.target!r} commits " + f"{current_ref} and {required_tag.sha}: commit histories diverged" + ) + if status in {"ahead", "identical"}: + return current_ref + raise RepoPolicySyncError( + f"cannot compare GitHub target {operation.target!r} commits " + f"{current_ref} and {required_tag.sha}: unknown comparison status {status!r}" + ) + + return replacement + + +def _workflow_files(root: Path) -> tuple[Path, ...]: + workflow_directory = root / ".github" / "workflows" + validate_repository_path(root, workflow_directory) + if not workflow_directory.exists(): + return () + if not workflow_directory.is_dir(): + raise RepoPolicySyncError(".github/workflows must be a directory") + paths = tuple( + sorted( + path + for path in workflow_directory.rglob("*") + if path.is_file() and path.suffix in {".yml", ".yaml"} + ) + ) + for path in paths: + validate_repository_path(root, path) + return paths + + +def _references(text: str, target: str) -> tuple[_WorkflowReference, ...]: + blocked_ranges = _block_scalar_ranges(text) + return tuple( + _WorkflowReference( + ref=match.group("ref"), + start=match.start("ref"), + end=match.end("ref"), + ) + for match in _USES_REFERENCE.finditer(text) + if match.group("target") == target + and not any(start <= match.start() < end for start, end in blocked_ranges) + ) + + +def _workflow_references(root: Path, target: str) -> tuple[_WorkflowReference, ...]: + """Collect matching entries before deciding whether remote data is needed.""" + + references: list[_WorkflowReference] = [] + for path in _workflow_files(root): + references.extend(_references(_read_workflow(path), target)) + return tuple(references) + + +def _block_scalar_ranges(text: str) -> tuple[tuple[int, int], ...]: + """Return content spans of YAML literal/folded scalar values. + + A shell script in ``run: |`` can contain text that looks like a workflow + key. It is data, not a ``uses`` property, so it must not be rewritten. + This small indentation scan avoids a YAML round trip while retaining the + source formatting needed by the operation. + """ + + lines = text.splitlines(keepends=True) + offsets: list[int] = [] + offset = 0 + for line in lines: + offsets.append(offset) + offset += len(line) + ranges: list[tuple[int, int]] = [] + index = 0 + while index < len(lines): + line = lines[index] + if _BLOCK_SCALAR_HEADER.fullmatch(line): + base_indent = len(line) - len(line.lstrip(" \t")) + content_start = offsets[index] + len(line) + end_index = index + 1 + while end_index < len(lines): + content_line = lines[end_index] + stripped = content_line.lstrip(" \t\r\n") + if ( + stripped + and len(content_line) - len(content_line.lstrip(" \t")) + <= base_indent + ): + break + end_index += 1 + content_end = offsets[end_index] if end_index < len(lines) else len(text) + if content_start < content_end: + ranges.append((content_start, content_end)) + index = end_index + continue + index += 1 + return tuple(ranges) + + +def _updated_text( + text: str, + references: tuple[_WorkflowReference, ...], + replacement_for: Callable[[str], str], +) -> tuple[str, int]: + replacements: list[tuple[int, int, str]] = [] + for reference in references: + replacement = replacement_for(reference.ref) + if replacement != reference.ref: + replacements.append((reference.start, reference.end, replacement)) + updated = text + for start, end, replacement in reversed(replacements): + updated = updated[:start] + replacement + updated[end:] + return updated, len(replacements) + + +def _describe_workflow_updates( + root: Path, + *, + target: str, + rationale: str | None, + replacement_for: Callable[[str], str], + description: Callable[[int], str], +) -> tuple[Change, ...]: + changes: list[Change] = [] + for path in _workflow_files(root): + text = _read_workflow(path) + references = _references(text, target) + _updated, count = _updated_text(text, references, replacement_for) + if count: + changes.append( + Change(path.relative_to(root), description(count), rationale) + ) + return tuple(changes) + + +def _apply_workflow_updates( + root: Path, + *, + target: str, + replacement_for: Callable[[str], str], +) -> None: + for path in _workflow_files(root): + text = _read_workflow(path) + references = _references(text, target) + updated, count = _updated_text(text, references, replacement_for) + if count: + # Read/write bytes so Python does not normalize CRLF workflow files + # while changing only the ref span. + path.write_bytes(updated.encode("utf-8")) + + +def _read_workflow(path: Path) -> str: + """Read UTF-8 workflow text without normalizing its line endings.""" + + return path.read_bytes().decode("utf-8") + + +def _parse_semantic_version(value: str) -> _SemanticVersion | None: + match = _SEMVER.fullmatch(value) + if match is None: + return None + prerelease = match.group("prerelease") + return _SemanticVersion( + major=int(match.group("major")), + minor=int(match.group("minor") or 0), + patch=int(match.group("patch") or 0), + prerelease=tuple(prerelease.split(".")) if prerelease else (), + ) + + +def _compare_versions(left: _SemanticVersion, right: _SemanticVersion) -> int: + left_core = (left.major, left.minor, left.patch) + right_core = (right.major, right.minor, right.patch) + if left_core != right_core: + return (left_core > right_core) - (left_core < right_core) + if not left.prerelease or not right.prerelease: + return (not left.prerelease) - (not right.prerelease) + for left_part, right_part in zip(left.prerelease, right.prerelease): + if left_part == right_part: + continue + left_numeric = left_part.isdecimal() + right_numeric = right_part.isdecimal() + if left_numeric and right_numeric: + return (int(left_part) > int(right_part)) - ( + int(left_part) < int(right_part) + ) + if left_numeric != right_numeric: + return -1 if left_numeric else 1 + return (left_part > right_part) - (left_part < right_part) + return (len(left.prerelease) > len(right.prerelease)) - ( + len(left.prerelease) < len(right.prerelease) + ) + + +def _compare_tag_candidates( + left: tuple[GitHubTag, _SemanticVersion], + right: tuple[GitHubTag, _SemanticVersion], +) -> int: + comparison = _compare_versions(left[1], right[1]) + if comparison: + return comparison + left_tie_breaker = (len(left[0].name), left[0].name) + right_tie_breaker = (len(right[0].name), right[0].name) + return (left_tie_breaker > right_tie_breaker) - ( + left_tie_breaker < right_tie_breaker + ) diff --git a/repo_policy_sync/src/operations/ensure_line.py b/repo_policy_sync/src/operations/ensure_line.py index c8d1717..1b1deb7 100644 --- a/repo_policy_sync/src/operations/ensure_line.py +++ b/repo_policy_sync/src/operations/ensure_line.py @@ -64,6 +64,7 @@ def describe_changes( operation: EnsureOperation, *, organization: str | None = None, + github_resolver=None, ) -> tuple[Change, ...]: assert isinstance(operation, EnsureLine) path = root / operation.path @@ -95,6 +96,7 @@ def apply( operation: EnsureOperation, *, organization: str | None = None, + github_resolver=None, ) -> None: assert isinstance(operation, EnsureLine) path = root / operation.path diff --git a/repo_policy_sync/src/operations/ensure_minimum_version.py b/repo_policy_sync/src/operations/ensure_minimum_version.py index cc31ded..005bf94 100644 --- a/repo_policy_sync/src/operations/ensure_minimum_version.py +++ b/repo_policy_sync/src/operations/ensure_minimum_version.py @@ -53,6 +53,7 @@ def describe_changes( operation: EnsureOperation, *, organization: str | None = None, + github_resolver=None, ) -> tuple[Change, ...]: assert isinstance(operation, EnsureMinimumVersion) path = root / operation.path @@ -74,6 +75,7 @@ def apply( operation: EnsureOperation, *, organization: str | None = None, + github_resolver=None, ) -> None: assert isinstance(operation, EnsureMinimumVersion) path = root / operation.path diff --git a/repo_policy_sync/src/operations/remove_file.py b/repo_policy_sync/src/operations/remove_file.py index da479bc..5d245de 100644 --- a/repo_policy_sync/src/operations/remove_file.py +++ b/repo_policy_sync/src/operations/remove_file.py @@ -44,6 +44,7 @@ def describe_changes( operation: EnsureOperation, *, organization: str | None = None, + github_resolver=None, ) -> tuple[Change, ...]: assert isinstance(operation, RemoveFile) path = root / operation.path @@ -62,6 +63,7 @@ def apply( operation: EnsureOperation, *, organization: str | None = None, + github_resolver=None, ) -> None: assert isinstance(operation, RemoveFile) path = root / operation.path diff --git a/repo_policy_sync/src/operations/replace_regex.py b/repo_policy_sync/src/operations/replace_regex.py index 4eaeb5c..f5a32d6 100644 --- a/repo_policy_sync/src/operations/replace_regex.py +++ b/repo_policy_sync/src/operations/replace_regex.py @@ -57,6 +57,7 @@ def describe_changes( operation: EnsureOperation, *, organization: str | None = None, + github_resolver=None, ) -> tuple[Change, ...]: assert isinstance(operation, ReplaceRegex) path = root / operation.path @@ -77,6 +78,7 @@ def apply( operation: EnsureOperation, *, organization: str | None = None, + github_resolver=None, ) -> None: assert isinstance(operation, ReplaceRegex) path = root / operation.path diff --git a/repo_policy_sync/src/runner.py b/repo_policy_sync/src/runner.py index 2e5b862..aba9ff2 100644 --- a/repo_policy_sync/src/runner.py +++ b/repo_policy_sync/src/runner.py @@ -33,6 +33,7 @@ from .errors import RepoPolicySyncError, redact_sensitive_text from .github import ( CommitResult, + GitHubResolver, PolicyPullRequestStatus, TOOL_SLUG, _pull_request_body, @@ -247,6 +248,10 @@ def run_policies( pull_requests_recreated ) = pull_requests_closed = 0 outcomes: list[RepositoryOutcome] = [] + # Share immutable GitHub target metadata across policy and repository + # workers. The resolver owns synchronization and is scoped to this run so + # a later run can observe newly published target tags. + github_resolver = GitHubResolver() for policy in policies: policy_outcomes = _run_policy_across_repositories( client=client, @@ -264,6 +269,7 @@ def run_policies( progress=report_progress, include_pull_request_status=include_pull_request_status, tool_revision=tool_revision, + github_resolver=github_resolver, ) outcomes.extend(policy_outcomes) for outcome in policy_outcomes: @@ -339,9 +345,11 @@ def _run_policy_across_repositories( progress: Callable[[str], None], include_pull_request_status: bool, tool_revision: str | None, + github_resolver: GitHubResolver | None = None, ) -> tuple[RepositoryOutcome, ...]: """Evaluate or apply one policy in independent repository checkouts concurrently.""" + github_resolver = github_resolver or GitHubResolver() progress( f"{policy.id}: processing {len(repositories)} repositories with {workers} worker(s)..." ) @@ -375,6 +383,7 @@ def _run_policy_across_repositories( pull_request_template=pull_request_template, include_pull_request_status=include_pull_request_status, tool_revision=tool_revision, + github_resolver=github_resolver, ) ] = (index, repository) for completed, future in enumerate(as_completed(futures), start=1): @@ -409,7 +418,9 @@ def _run_policy_in_repository( pull_request_template: str | None, include_pull_request_status: bool, tool_revision: str | None, + github_resolver: GitHubResolver | None = None, ) -> RepositoryOutcome: + github_resolver = github_resolver or GitHubResolver() restore_synced_default_branch(checkout=checkout) if ( repository.default_branch is None @@ -428,6 +439,7 @@ def _run_policy_in_repository( pull_request_template=pull_request_template, include_pull_request_status=include_pull_request_status, tool_revision=tool_revision, + github_resolver=github_resolver, ) @@ -445,7 +457,9 @@ def _run_repository( pull_request_template: str | None = None, include_pull_request_status: bool = False, tool_revision: str | None = None, + github_resolver: GitHubResolver | None = None, ) -> RepositoryOutcome: + github_resolver = github_resolver or GitHubResolver() if apply and tool_revision is None: # Keep direct private callers safe as well as the organization-level # entry point: provenance must be known before branch mutation. @@ -461,7 +475,12 @@ def _run_repository( else None ) try: - evaluation = evaluate_policy(checkout, policy, organization=org) + evaluation = evaluate_policy( + checkout, + policy, + organization=org, + github_resolver=github_resolver, + ) except RepoPolicySyncError as exc: if apply: existing_pr = client.find_open_pull_request( @@ -532,6 +551,7 @@ def _run_repository( return _recreate_repository( client=client, organization=org, + github_resolver=github_resolver, repository=repository, full_name=full_name, policy=policy, @@ -616,7 +636,12 @@ def _run_repository( client.switch_to_policy_branch( checkout=checkout, branch=branch, exists_remotely=existing_pr is not None ) - applied = apply_policy(checkout, policy, organization=org) + applied = apply_policy( + checkout, + policy, + organization=org, + github_resolver=github_resolver, + ) head_oid = existing_pr.expected_head_oid if existing_pr is not None else "" pre_commit_failure = None if applied.changes: @@ -672,6 +697,7 @@ def _run_repository( allow_dirty_pr=allow_dirty_pr, tool_revision=tool_revision, pull_request_template=pull_request_template, + github_resolver=github_resolver, ) if _pull_request_body_changed( existing_pr, @@ -886,6 +912,7 @@ def _recreate_repository( allow_dirty_pr: bool = False, tool_revision: str | None = None, pull_request_template: str | None = None, + github_resolver: GitHubResolver | None = None, ) -> RepositoryOutcome: """Rebuild an existing policy branch from the freshly synced default branch.""" @@ -911,6 +938,7 @@ def _recreate_repository( allow_dirty_pr=allow_dirty_pr, tool_revision=tool_revision, pull_request_template=pull_request_template, + github_resolver=github_resolver, ) @@ -927,6 +955,7 @@ def _recreate_existing_pull_request( allow_dirty_pr: bool = False, tool_revision: str | None = None, pull_request_template: str | None = None, + github_resolver: GitHubResolver | None = None, ) -> RepositoryOutcome: """Rebuild one known policy PR from the freshly synchronized default branch.""" @@ -944,7 +973,11 @@ def _recreate_existing_pull_request( ) client.recreate_policy_branch(checkout=checkout, branch=branch) applied = apply_policy( - checkout, policy, force_after_apply=True, organization=organization + checkout, + policy, + force_after_apply=True, + organization=organization, + github_resolver=github_resolver, ) if not client.has_changes(checkout=checkout, changes=applied.changes): body_changes = applied.changes if changes is None else changes diff --git a/repo_policy_sync/tests/operations/test_ensure_github_ref.py b/repo_policy_sync/tests/operations/test_ensure_github_ref.py new file mode 100644 index 0000000..2399d21 --- /dev/null +++ b/repo_policy_sync/tests/operations/test_ensure_github_ref.py @@ -0,0 +1,423 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +import json +from pathlib import Path + +import pytest + +from repo_policy_sync.src.engine import apply_policy, evaluate_policy +from repo_policy_sync.src.errors import PolicyError, RepoPolicySyncError +from repo_policy_sync.src.github import GitHubCli +from repo_policy_sync.src.models import ( + EnsureExactGitHubRef, + EnsureMinimalGitHubRef, + Policy, +) +from repo_policy_sync.src.policy import load_policy + + +CHECKOUT = "actions/checkout" +SETUP_PYTHON = "actions/setup-python" +BAZEL_CACHE = "eclipse-score/cicd-actions/setup-bazel-cache" +REUSABLE_WORKFLOW = "eclipse-score/cicd-actions/.github/workflows/reusable.yml" +BAZEL_CACHE_REPOSITORY = "eclipse-score/cicd-actions" +OLD_SHA = "a" * 40 +MINIMUM_SHA = "b" * 40 +NEW_SHA = "c" * 40 + + +def _policy(operations) -> Policy: + return Policy("example", "Update GitHub Actions", None, None, operations) + + +def _mock_gh_api(monkeypatch, *, statuses: dict[str, str] | None = None): + calls: list[list[str]] = [] + statuses = statuses or {} + + def run(command: list[str]) -> str: + calls.append(command) + route = command[-1] + if route in { + f"/repos/{SETUP_PYTHON}/tags?per_page=100", + f"/repos/{BAZEL_CACHE_REPOSITORY}/tags?per_page=100", + }: + return json.dumps( + [ + [ + {"name": "v5.1", "commit": {"sha": MINIMUM_SHA}}, + {"name": "v5.2", "commit": {"sha": NEW_SHA}}, + ] + ] + ) + if route.startswith( + ( + f"/repos/{SETUP_PYTHON}/compare/", + f"/repos/{BAZEL_CACHE_REPOSITORY}/compare/", + ) + ): + current = route.rsplit("...", 1)[1] + return json.dumps({"status": statuses[current]}) + raise AssertionError(f"unexpected gh api request: {command}") + + monkeypatch.setattr(GitHubCli, "_run", staticmethod(run)) + return calls + + +def test_policy_parser_accepts_exact_and_minimal_github_target_operations( + fake_repo: Path, +) -> None: + policy_path = fake_repo / "example" / "policy.yml" + policy_path.parent.mkdir() + policy_path.write_text( + """title: Update GitHub Actions +ensure: + - type: ensure_exact + target: eclipse-score/cicd-actions/setup-bazel-cache + ref: 0123456789abcdef0123456789abcdef01234567 + - type: ensure_minimal + target: eclipse-score/cicd-actions/.github/workflows/reusable.yml + minimum_version: v5.1 +""", + encoding="utf-8", + ) + + policy = load_policy(policy_path) + + assert policy.ensure == ( + EnsureExactGitHubRef(BAZEL_CACHE, "0123456789abcdef0123456789abcdef01234567"), + EnsureMinimalGitHubRef(REUSABLE_WORKFLOW, "v5.1"), + ) + + +@pytest.mark.parametrize("ref", ("v4", "release/stable", "0" * 40)) +def test_ensure_exact_updates_tags_branches_and_shas(fake_repo: Path, ref: str) -> None: + workflow = fake_repo / ".github/workflows/ci.yml" + workflow.parent.mkdir(parents=True) + workflow.write_text( + f"jobs:\n build:\n uses: {CHECKOUT}@{ref} # keep this comment\n", + encoding="utf-8", + ) + desired = "0123456789abcdef0123456789abcdef01234567" + + evaluation = apply_policy( + fake_repo, + _policy((EnsureExactGitHubRef(CHECKOUT, desired),)), + ) + + assert len(evaluation.changes) == 1 + assert f"uses: {CHECKOUT}@{desired} # keep this comment" in workflow.read_text() + + +def test_ensure_exact_does_not_call_github_for_a_matching_action( + fake_repo: Path, monkeypatch +) -> None: + workflow = fake_repo / ".github/workflows/ci.yml" + workflow.parent.mkdir(parents=True) + workflow.write_text(f"jobs:\n build:\n uses: {CHECKOUT}@v4\n") + calls = [] + monkeypatch.setattr( + GitHubCli, + "_run", + staticmethod(lambda command: calls.append(command) or ""), + ) + + assert ( + evaluate_policy( + fake_repo, _policy((EnsureExactGitHubRef(CHECKOUT, "v4"),)) + ).changes + == () + ) + assert calls == [] + + +def test_ensure_exact_updates_sequence_style_step_reference(fake_repo: Path) -> None: + workflow = fake_repo / ".github/workflows/ci.yml" + workflow.parent.mkdir(parents=True) + workflow.write_text(f"jobs:\n build:\n steps:\n - uses: {CHECKOUT}@v4\n") + desired = "0123456789abcdef0123456789abcdef01234567" + + evaluation = apply_policy( + fake_repo, + _policy((EnsureExactGitHubRef(CHECKOUT, desired),)), + ) + + assert len(evaluation.changes) == 1 + assert f" - uses: {CHECKOUT}@{desired}\n" in workflow.read_text() + + +def test_ensure_exact_updates_nested_action_without_touching_sibling_targets( + fake_repo: Path, +) -> None: + workflow = fake_repo / ".github/workflows/ci.yml" + workflow.parent.mkdir(parents=True) + workflow.write_text( + "jobs:\n" + " build:\n" + f" uses: {BAZEL_CACHE}@v1\n" + " other:\n" + f" uses: {BAZEL_CACHE_REPOSITORY}/other-action@v1\n" + " reusable:\n" + f" uses: {REUSABLE_WORKFLOW}@v1\n" + ) + desired = "0123456789abcdef0123456789abcdef01234567" + + apply_policy( + fake_repo, + _policy((EnsureExactGitHubRef(BAZEL_CACHE, desired),)), + ) + + contents = workflow.read_text() + assert f"uses: {BAZEL_CACHE}@{desired}" in contents + assert f"uses: {BAZEL_CACHE_REPOSITORY}/other-action@v1" in contents + assert f"uses: {REUSABLE_WORKFLOW}@v1" in contents + + +def test_ensure_exact_updates_external_reusable_workflow_reference( + fake_repo: Path, +) -> None: + workflow = fake_repo / ".github/workflows/call-reusable.yaml" + workflow.parent.mkdir(parents=True) + workflow.write_text(f"jobs:\n verify:\n uses: {REUSABLE_WORKFLOW}@v1\n") + + apply_policy( + fake_repo, + _policy((EnsureExactGitHubRef(REUSABLE_WORKFLOW, "release/stable"),)), + ) + + assert f"uses: {REUSABLE_WORKFLOW}@release/stable" in workflow.read_text() + + +def test_action_ref_updates_preserve_crlf_workflow_formatting(fake_repo: Path) -> None: + workflow = fake_repo / ".github/workflows/ci.yml" + workflow.parent.mkdir(parents=True) + workflow.write_bytes( + f"jobs:\r\n build:\r\n uses: {CHECKOUT}@v3 # comment\r\n".encode() + ) + + apply_policy( + fake_repo, + _policy((EnsureExactGitHubRef(CHECKOUT, "v4"),)), + ) + + assert workflow.read_bytes() == ( + f"jobs:\r\n build:\r\n uses: {CHECKOUT}@v4 # comment\r\n".encode() + ) + + +def test_action_ref_ignores_uses_text_inside_run_scalar(fake_repo: Path) -> None: + workflow = fake_repo / ".github/workflows/ci.yml" + workflow.parent.mkdir(parents=True) + workflow.write_text( + "jobs:\n" + " build:\n" + " runs-on: ubuntu-latest\n" + " steps:\n" + " - run: |\n" + f" uses: {CHECKOUT}@v3\n" + ) + + assert ( + evaluate_policy( + fake_repo, _policy((EnsureExactGitHubRef(CHECKOUT, "v4"),)) + ).changes + == () + ) + + +@pytest.mark.parametrize( + ("current", "expected"), + (("v5.0", "v5.1"), ("v5.1", "v5.1"), ("v5.2", "v5.2")), +) +def test_ensure_minimal_updates_only_older_semver_tags( + fake_repo: Path, + monkeypatch, + current: str, + expected: str, +) -> None: + workflow = fake_repo / ".github/workflows/ci.yml" + workflow.parent.mkdir(parents=True) + workflow.write_text( + f"jobs:\n build:\n steps:\n - uses: {SETUP_PYTHON}@{current}\n" + ) + _mock_gh_api(monkeypatch) + + evaluation = apply_policy( + fake_repo, + _policy((EnsureMinimalGitHubRef(SETUP_PYTHON, "v5.1"),)), + ) + + assert f"uses: {SETUP_PYTHON}@{expected}" in workflow.read_text() + assert bool(evaluation.changes) is (current == "v5.0") + + +def test_ensure_minimal_is_idempotent_after_updating_a_tag( + fake_repo: Path, monkeypatch +) -> None: + workflow = fake_repo / ".github/workflows/ci.yml" + workflow.parent.mkdir(parents=True) + workflow.write_text(f"jobs:\n build:\n uses: {SETUP_PYTHON}@v5.0\n") + calls = _mock_gh_api(monkeypatch) + policy = _policy((EnsureMinimalGitHubRef(SETUP_PYTHON, "v5.1"),)) + + assert apply_policy(fake_repo, policy).changes + assert apply_policy(fake_repo, policy).changes == () + assert sum("/tags?" in command[-1] for command in calls) == 2 + + +@pytest.mark.parametrize( + ("status", "changed"), + (("behind", True), ("identical", False), ("ahead", False)), +) +def test_ensure_minimal_compares_full_sha_pins( + fake_repo: Path, monkeypatch, status: str, changed: bool +) -> None: + workflow = fake_repo / ".github/workflows/ci.yml" + workflow.parent.mkdir(parents=True) + workflow.write_text(f"jobs:\n build:\n uses: {SETUP_PYTHON}@{OLD_SHA}\n") + calls = _mock_gh_api(monkeypatch, statuses={OLD_SHA: status}) + policy = _policy((EnsureMinimalGitHubRef(SETUP_PYTHON, "v5.1"),)) + + evaluation = apply_policy(fake_repo, policy) + + assert (f"uses: {SETUP_PYTHON}@{MINIMUM_SHA}" in workflow.read_text()) is changed + assert bool(evaluation.changes) is changed + assert sum("/tags?" in command[-1] for command in calls) == 1 + assert sum("/compare/" in command[-1] for command in calls) == 1 + + +def test_ensure_minimal_rejects_diverged_sha_histories( + fake_repo: Path, monkeypatch +) -> None: + workflow = fake_repo / ".github/workflows/ci.yml" + workflow.parent.mkdir(parents=True) + workflow.write_text(f"jobs:\n build:\n uses: {SETUP_PYTHON}@{OLD_SHA}\n") + _mock_gh_api(monkeypatch, statuses={OLD_SHA: "diverged"}) + + with pytest.raises(RepoPolicySyncError, match="histories diverged"): + evaluate_policy( + fake_repo, + _policy((EnsureMinimalGitHubRef(SETUP_PYTHON, "v5.1"),)), + ) + + +def test_ensure_minimal_updates_nested_action_and_reusable_workflow_sha_pins( + fake_repo: Path, monkeypatch +) -> None: + workflows = fake_repo / ".github/workflows" + nested = workflows / "nested" + nested.mkdir(parents=True) + action_workflow = workflows / "action.yml" + reusable_workflow = nested / "reusable.yaml" + action_workflow.write_text(f"jobs:\n build:\n uses: {BAZEL_CACHE}@{OLD_SHA}\n") + reusable_workflow.write_text( + f"jobs:\n call:\n uses: {REUSABLE_WORKFLOW}@{OLD_SHA}\n" + ) + calls = _mock_gh_api(monkeypatch, statuses={OLD_SHA: "behind"}) + policy = _policy( + ( + EnsureMinimalGitHubRef(BAZEL_CACHE, "v5.1"), + EnsureMinimalGitHubRef(REUSABLE_WORKFLOW, "v5.1"), + ) + ) + + evaluation = apply_policy(fake_repo, policy) + + assert f"{BAZEL_CACHE}@{MINIMUM_SHA}" in action_workflow.read_text() + assert f"{REUSABLE_WORKFLOW}@{MINIMUM_SHA}" in reusable_workflow.read_text() + assert len(evaluation.changes) == 2 + assert sum("/tags?" in command[-1] for command in calls) == 1 + assert sum("/compare/" in command[-1] for command in calls) == 1 + + +def test_one_policy_can_update_multiple_actions_and_workflow_files( + fake_repo: Path, monkeypatch +) -> None: + workflows = fake_repo / ".github/workflows" + nested = workflows / "nested" + nested.mkdir(parents=True) + first = workflows / "a.yml" + second = nested / "b.yaml" + first.write_text( + f"jobs:\n one:\n uses: {CHECKOUT}@v3\n" + f" two:\n uses: {CHECKOUT}@v3 # comment\n" + ) + second.write_text(f"jobs:\n python:\n uses: {SETUP_PYTHON}@v5.0\n") + calls = _mock_gh_api(monkeypatch) + policy = _policy( + ( + EnsureExactGitHubRef(CHECKOUT, "v4"), + EnsureMinimalGitHubRef(SETUP_PYTHON, "v5.1"), + ) + ) + + evaluation = apply_policy(fake_repo, policy) + + assert {change.path for change in evaluation.changes} == { + Path(".github/workflows/a.yml"), + Path(".github/workflows/nested/b.yaml"), + } + assert first.read_text().count(f"{CHECKOUT}@v4") == 2 + assert "# comment" in first.read_text() + assert f"{SETUP_PYTHON}@v5.1" in second.read_text() + assert sum("/tags?" in command[-1] for command in calls) == 1 + + +def test_minimal_leaves_branches_and_missing_matches_without_remote_calls( + fake_repo: Path, monkeypatch +) -> None: + workflow = fake_repo / ".github/workflows/ci.yml" + workflow.parent.mkdir(parents=True) + workflow.write_text( + "# uses: actions/setup-python@v1\n" + f"jobs:\n build:\n uses: {SETUP_PYTHON}@main\n" + ) + calls = [] + monkeypatch.setattr( + GitHubCli, + "_run", + staticmethod(lambda command: calls.append(command) or ""), + ) + policy = _policy((EnsureMinimalGitHubRef(SETUP_PYTHON, "v5.1"),)) + + assert evaluate_policy(fake_repo, policy).changes == () + assert apply_policy(fake_repo, policy).changes == () + assert calls == [] + + +def test_action_operation_validation_rejects_local_actions_and_bad_versions( + fake_repo: Path, +) -> None: + policy_path = fake_repo / "example" / "policy.yml" + policy_path.parent.mkdir() + policy_path.write_text( + """title: Example +ensure: + - type: ensure_exact + target: ./.github/actions/local + ref: main +""" + ) + with pytest.raises(PolicyError, match="local targets are not supported"): + load_policy(policy_path) + + policy_path.write_text( + """title: Example +ensure: + - type: ensure_minimal + target: actions/checkout + minimum_version: latest +""" + ) + with pytest.raises(PolicyError, match="minimum_version must be a semantic version"): + load_policy(policy_path) diff --git a/repo_policy_sync/tests/test_runner.py b/repo_policy_sync/tests/test_runner.py index ad2ecee..cec4504 100644 --- a/repo_policy_sync/tests/test_runner.py +++ b/repo_policy_sync/tests/test_runner.py @@ -1281,6 +1281,15 @@ def test_existing_compliant_conflicted_pull_request_is_recreated( (EnsureLine(Path(".bazelversion"), "8.6.0", ()),), ) client = ImplicitRecreateClient(mergeable="CONFLICTING", body="stale body") + github_resolver = object() + observed_resolvers: list[object] = [] + original_apply_policy = runner.apply_policy + + def track_resolver(*args: object, **kwargs: object) -> Evaluation: + observed_resolvers.append(kwargs["github_resolver"]) + return original_apply_policy(*args, **kwargs) + + monkeypatch.setattr(runner, "apply_policy", track_resolver) monkeypatch.setattr( runner, "restore_synced_default_branch", @@ -1295,12 +1304,14 @@ def test_existing_compliant_conflicted_pull_request_is_recreated( policy=policy, checkout=checkout, apply=True, + github_resolver=github_resolver, ) assert outcome.status == "pull-request-recreated" assert client.recreated_branch assert client.force_pushed assert client.updated + assert observed_resolvers == [github_resolver, github_resolver] def test_existing_compliant_pull_request_is_left_alone_with_current_body(