Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 41 additions & 3 deletions repo_policy_sync/src/operations/ensure_github_ref.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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":
Expand All @@ -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)
Expand Down
18 changes: 18 additions & 0 deletions repo_policy_sync/tests/operations/test_ensure_github_ref.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down