From b335f436d754c4d3e461309b86457d909c2e3df2 Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Tue, 15 Sep 2026 17:02:35 +0200 Subject: [PATCH] fix(repo-policy-sync): recognize tagged newer SHA pins --- .../src/operations/ensure_github_ref.py | 44 +++++++++++++++++-- .../operations/test_ensure_github_ref.py | 18 ++++++++ 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/repo_policy_sync/src/operations/ensure_github_ref.py b/repo_policy_sync/src/operations/ensure_github_ref.py index ba9ccc5..b639e1c 100644 --- a/repo_policy_sync/src/operations/ensure_github_ref.py +++ b/repo_policy_sync/src/operations/ensure_github_ref.py @@ -272,6 +272,8 @@ def _minimal_replacement_for( ) -> Callable[[str], str]: required_version = _parse_semantic_version(required_tag.name) assert required_version is not None + repository = _repository_for(operation.target) + tagged_versions = _tagged_release_versions(resolver, repository) def replacement(current_ref: str) -> str: current_version = _parse_semantic_version(current_ref) @@ -287,9 +289,21 @@ def replacement(current_ref: str) -> str: 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 - ) + + # A release tag is the authoritative semantic-version information for + # its commit. This handles newer release lines whose histories do not + # descend from the configured minimum tag. + tagged_version = tagged_versions.get(current_ref.lower()) + if tagged_version is not None: + if _compare_versions(tagged_version, required_version) >= 0: + return current_ref + else: + return required_tag.sha + + # Without a release tag, ancestry is the only available ordering + # signal. Keep rejecting diverged histories because timestamps cannot + # safely establish that an untagged commit meets the minimum version. + status = resolver.compare_commits(repository, required_tag.sha, current_ref) if status == "behind": return required_tag.sha if status == "diverged": @@ -307,6 +321,30 @@ def replacement(current_ref: str) -> str: return replacement +def _tagged_release_versions( + resolver: GitHubResolver, repository: str +) -> dict[str, _SemanticVersion]: + """Index the highest semantic release version associated with each SHA. + + A repository may maintain multiple release branches at the same time, so + the commit behind a newer release tag is not required to be an ancestor of + the commit behind an older minimum tag. Mapping tags to versions lets the + minimum-version policy compare those commits semantically while retaining + ancestry comparison for commits that have no release tag. + """ + + tagged_versions: dict[str, _SemanticVersion] = {} + for tag in resolver.tags(repository): + version = _parse_semantic_version(tag.name) + if version is None: + continue + normalized_sha = tag.sha.lower() + previous_version = tagged_versions.get(normalized_sha) + if previous_version is None or _compare_versions(version, previous_version) > 0: + tagged_versions[normalized_sha] = version + return tagged_versions + + def _workflow_files(root: Path) -> tuple[Path, ...]: workflow_directory = root / ".github" / "workflows" validate_repository_path(root, workflow_directory) diff --git a/repo_policy_sync/tests/operations/test_ensure_github_ref.py b/repo_policy_sync/tests/operations/test_ensure_github_ref.py index 2399d21..8d91ccf 100644 --- a/repo_policy_sync/tests/operations/test_ensure_github_ref.py +++ b/repo_policy_sync/tests/operations/test_ensure_github_ref.py @@ -296,6 +296,24 @@ def test_ensure_minimal_compares_full_sha_pins( assert sum("/compare/" in command[-1] for command in calls) == 1 +def test_ensure_minimal_keeps_tagged_newer_sha_without_comparing_histories( + fake_repo: Path, monkeypatch +) -> None: + """A newer tagged release remains valid when release histories diverge.""" + + workflow = fake_repo / ".github/workflows/ci.yml" + workflow.parent.mkdir(parents=True) + workflow.write_text(f"jobs:\n build:\n uses: {SETUP_PYTHON}@{NEW_SHA}\n") + calls = _mock_gh_api(monkeypatch, statuses={NEW_SHA: "diverged"}) + policy = _policy((EnsureMinimalGitHubRef(SETUP_PYTHON, "v5.1"),)) + + evaluation = evaluate_policy(fake_repo, policy) + + assert evaluation.changes == () + assert f"uses: {SETUP_PYTHON}@{NEW_SHA}" in workflow.read_text() + assert sum("/compare/" in command[-1] for command in calls) == 0 + + def test_ensure_minimal_rejects_diverged_sha_histories( fake_repo: Path, monkeypatch ) -> None: