feat(release): publish verified source tags
This commit is contained in:
@@ -18,6 +18,7 @@ __all__ = [
|
||||
"prepare_repositories",
|
||||
"push_repositories",
|
||||
"sync_repositories",
|
||||
"tag_repositories",
|
||||
]
|
||||
|
||||
|
||||
@@ -64,4 +65,8 @@ def _load_lazy_export(name: str):
|
||||
from .repository_sync import sync_repositories
|
||||
|
||||
return sync_repositories
|
||||
if name == "tag_repositories":
|
||||
from .repository_tag import tag_repositories
|
||||
|
||||
return tag_repositories
|
||||
raise AttributeError(name)
|
||||
|
||||
@@ -15,6 +15,7 @@ from .catalog import canonical_hash
|
||||
from .model import CatalogPublishResult, CatalogPublishStep
|
||||
from .module_directory import write_module_directory
|
||||
from .selective_catalog import trusted_keys_from_keyring
|
||||
from .source_provenance import catalog_source_selection, source_tag_provenance_issues
|
||||
from .version_alignment import candidate_catalog_version_issues
|
||||
from .workspace import DEFAULT_WORKSPACE_ROOT, resolve_workspace_root, website_root
|
||||
|
||||
@@ -31,6 +32,7 @@ def publish_catalog_candidate(
|
||||
tag: bool = False,
|
||||
push: bool = False,
|
||||
remote: str = "origin",
|
||||
source_remote: str = "origin",
|
||||
branch: str | None = None,
|
||||
npm: str = "npm",
|
||||
tag_name: str | None = None,
|
||||
@@ -79,6 +81,23 @@ def publish_catalog_candidate(
|
||||
f"expected {issue.expected!r} ({issue.message})"
|
||||
for issue in version_issues
|
||||
)
|
||||
source_selection = catalog_source_selection(candidate_payload)
|
||||
blockers.extend(
|
||||
f"source provenance: {issue.describe()}"
|
||||
for issue in source_selection.issues
|
||||
)
|
||||
source_issues = source_tag_provenance_issues(
|
||||
repo_versions=source_selection.all_versions,
|
||||
workspace=workspace,
|
||||
remote=source_remote,
|
||||
require_head_repos=source_selection.selected_versions,
|
||||
expected_commits=source_selection.selected_commits,
|
||||
expected_tag_objects=source_selection.selected_tag_objects,
|
||||
)
|
||||
blockers.extend(
|
||||
f"source provenance: {issue.describe()}"
|
||||
for issue in source_issues
|
||||
)
|
||||
candidate_keys = trusted_keys_from_keyring(keyring_payload if isinstance(keyring_payload, dict) else {})
|
||||
target_keyring_payload = read_json(target_keyring) if target_keyring.exists() else None
|
||||
publication_trust = trusted_keys_from_keyring(
|
||||
|
||||
426
tools/release/govoplan_release/repository_tag.py
Normal file
426
tools/release/govoplan_release/repository_tag.py
Normal file
@@ -0,0 +1,426 @@
|
||||
"""Preview, create, and publish immutable release tags for selected repositories."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
from .git_state import collect_repository_snapshot, git_text
|
||||
from .model import RepositorySnapshot
|
||||
from .repository_push import command_text, compact_output
|
||||
from .version_alignment import repository_version_issues
|
||||
from .workspace import load_repository_specs, resolve_repo_path, resolve_workspace_root
|
||||
|
||||
|
||||
_RELEASE_VERSION = re.compile(r"^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$")
|
||||
|
||||
|
||||
def tag_repositories(
|
||||
*,
|
||||
repos: tuple[str, ...],
|
||||
repo_versions: dict[str, str],
|
||||
workspace_root: Path | str | None = None,
|
||||
remote: str = "origin",
|
||||
message: str | None = None,
|
||||
apply: bool = False, # noqa: A002 - mirrors API field.
|
||||
push: bool = False,
|
||||
) -> dict[str, object]:
|
||||
"""Create annotated tags and optionally publish branch and tag atomically.
|
||||
|
||||
A release tag is only created for a clean, aligned, non-behind worktree.
|
||||
Both local and remote tags are resolved to commits before mutation so an
|
||||
existing immutable tag can never be moved by this operation.
|
||||
"""
|
||||
|
||||
workspace = resolve_workspace_root(workspace_root)
|
||||
remote = remote.strip() or "origin"
|
||||
specs = {spec.name: spec for spec in load_repository_specs(include_website=False)}
|
||||
selected = tuple(dict.fromkeys(repos))
|
||||
results: list[dict[str, object]] = []
|
||||
|
||||
if apply:
|
||||
preflight = tag_repositories(
|
||||
repos=selected,
|
||||
repo_versions=repo_versions,
|
||||
workspace_root=workspace,
|
||||
remote=remote,
|
||||
message=message,
|
||||
apply=False,
|
||||
push=push,
|
||||
)
|
||||
preflight_rows = preflight.get("repositories")
|
||||
if isinstance(preflight_rows, list) and any(
|
||||
isinstance(item, dict) and item.get("status") in {"blocked", "failed"}
|
||||
for item in preflight_rows
|
||||
):
|
||||
blocked_rows = []
|
||||
for item in preflight_rows:
|
||||
if not isinstance(item, dict) or item.get("status") in {"blocked", "failed"}:
|
||||
blocked_rows.append(item)
|
||||
continue
|
||||
blocked_rows.append(
|
||||
{
|
||||
**item,
|
||||
"status": "skipped",
|
||||
"detail": "preflight passed, but no release tag was changed because another selected repository is blocked",
|
||||
}
|
||||
)
|
||||
return {
|
||||
"status": "blocked",
|
||||
"apply": True,
|
||||
"push": push,
|
||||
"remote": remote,
|
||||
"detail": "batch preflight failed; no selected repository was mutated",
|
||||
"repositories": blocked_rows,
|
||||
}
|
||||
|
||||
for repo in selected:
|
||||
version = normalize_version(repo_versions.get(repo))
|
||||
tag = f"v{version}" if version else ""
|
||||
spec = specs.get(repo)
|
||||
if spec is None:
|
||||
results.append({"repo": repo, "status": "blocked", "detail": "repository is not listed in repositories.json"})
|
||||
continue
|
||||
snapshot = collect_repository_snapshot(spec, workspace_root=workspace, target_tag=tag or None, online=False)
|
||||
path = resolve_repo_path(spec, workspace)
|
||||
tag_message = message.strip() if message and message.strip() else f"Release {repo} {tag}"
|
||||
create_command = ("git", "tag", "-a", tag, "-m", tag_message) if tag else ()
|
||||
publish_command = (
|
||||
"git",
|
||||
"push",
|
||||
"--atomic",
|
||||
remote,
|
||||
f"HEAD:refs/heads/{snapshot.branch or 'main'}",
|
||||
f"refs/tags/{tag}",
|
||||
) if tag else ()
|
||||
row: dict[str, object] = {
|
||||
"repo": repo,
|
||||
"cwd": snapshot.absolute_path,
|
||||
"branch": snapshot.branch,
|
||||
"upstream": snapshot.upstream,
|
||||
"head": snapshot.head,
|
||||
"ahead": int(snapshot.ahead or 0),
|
||||
"behind": int(snapshot.behind or 0),
|
||||
"dirty_count": len(snapshot.dirty_entries),
|
||||
"version": version,
|
||||
"tag": tag,
|
||||
"remote": remote,
|
||||
"message": tag_message,
|
||||
"create_command": command_text(create_command) if create_command else "",
|
||||
"publish_command": command_text(publish_command) if publish_command else "",
|
||||
}
|
||||
blocker = basic_blocker(snapshot=snapshot, version=version)
|
||||
if blocker:
|
||||
results.append({**row, "status": "blocked", "detail": blocker})
|
||||
continue
|
||||
if run(("git", "remote", "get-url", remote), cwd=path).returncode != 0:
|
||||
results.append({**row, "status": "blocked", "detail": f"remote {remote!r} is not configured"})
|
||||
continue
|
||||
|
||||
alignment = repository_version_issues(path, expected_version=version)
|
||||
if alignment:
|
||||
detail = "; ".join(
|
||||
f"{issue.source}={issue.actual!r}, expected {issue.expected!r}"
|
||||
for issue in alignment
|
||||
)
|
||||
results.append({**row, "status": "blocked", "detail": f"version alignment gate failed: {detail}"})
|
||||
continue
|
||||
|
||||
head_commit = git_text(path, "rev-parse", "HEAD")
|
||||
local_commit = ref_commit(path, f"refs/tags/{tag}")
|
||||
local_tag_object = git_text(path, "rev-parse", "--verify", f"refs/tags/{tag}") if local_commit else None
|
||||
local_tag_type = git_text(path, "cat-file", "-t", f"refs/tags/{tag}") if local_commit else None
|
||||
if local_commit and local_tag_type != "tag":
|
||||
results.append(
|
||||
{
|
||||
**row,
|
||||
"status": "blocked",
|
||||
"detail": f"local tag {tag} is not annotated; release tags must preserve an annotation object",
|
||||
"local_tag_commit": local_commit,
|
||||
"local_tag_type": local_tag_type,
|
||||
}
|
||||
)
|
||||
continue
|
||||
if local_commit and local_commit != head_commit:
|
||||
results.append(
|
||||
{
|
||||
**row,
|
||||
"status": "blocked",
|
||||
"detail": f"local immutable tag {tag} points to {local_commit[:12]}, not HEAD {head_commit[:12]}",
|
||||
"local_tag_commit": local_commit,
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
remote_result = remote_tag_commit(path, remote=remote, tag=tag)
|
||||
if remote_result.error:
|
||||
results.append({**row, "status": "blocked", "detail": remote_result.error})
|
||||
continue
|
||||
if remote_result.commit and not remote_result.annotated:
|
||||
results.append(
|
||||
{
|
||||
**row,
|
||||
"status": "blocked",
|
||||
"detail": f"remote tag {tag} is not annotated; immutable release tags must preserve an annotation object",
|
||||
"local_tag_commit": local_commit,
|
||||
"remote_tag_commit": remote_result.commit,
|
||||
}
|
||||
)
|
||||
continue
|
||||
if remote_result.commit and remote_result.commit != head_commit:
|
||||
results.append(
|
||||
{
|
||||
**row,
|
||||
"status": "blocked",
|
||||
"detail": f"remote immutable tag {tag} points to {remote_result.commit[:12]}, not HEAD {head_commit[:12]}",
|
||||
"local_tag_commit": local_commit,
|
||||
"remote_tag_commit": remote_result.commit,
|
||||
}
|
||||
)
|
||||
continue
|
||||
if remote_result.tag_object and local_tag_object and remote_result.tag_object != local_tag_object:
|
||||
results.append(
|
||||
{
|
||||
**row,
|
||||
"status": "blocked",
|
||||
"detail": f"local and remote immutable tag objects differ for {tag}; reconcile the annotation without moving either tag",
|
||||
"local_tag_commit": local_commit,
|
||||
"remote_tag_commit": remote_result.commit,
|
||||
"local_tag_object": local_tag_object,
|
||||
"remote_tag_object": remote_result.tag_object,
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
row.update(
|
||||
{
|
||||
"head_commit": head_commit,
|
||||
"local_tag_commit": local_commit,
|
||||
"remote_tag_commit": remote_result.commit,
|
||||
"local_tag_object": local_tag_object,
|
||||
"remote_tag_object": remote_result.tag_object,
|
||||
}
|
||||
)
|
||||
if not apply:
|
||||
detail = preview_detail(tag=tag, local_commit=local_commit, remote_commit=remote_result.commit, push=push)
|
||||
status = "noop" if remote_result.commit or (local_commit and not push) else "planned"
|
||||
results.append({**row, "status": status, "detail": detail})
|
||||
continue
|
||||
|
||||
if remote_result.commit:
|
||||
if not local_commit:
|
||||
fetch_result = run(("git", "fetch", remote, f"refs/tags/{tag}:refs/tags/{tag}"), cwd=path)
|
||||
if fetch_result.returncode != 0:
|
||||
results.append(
|
||||
{
|
||||
**row,
|
||||
"status": "failed",
|
||||
"detail": f"remote tag {tag} exists at HEAD but could not be fetched locally",
|
||||
"returncode": fetch_result.returncode,
|
||||
"stdout": compact_output(fetch_result.stdout),
|
||||
"stderr": compact_output(fetch_result.stderr),
|
||||
}
|
||||
)
|
||||
continue
|
||||
results.append(
|
||||
{
|
||||
**row,
|
||||
"status": "published",
|
||||
"detail": f"immutable tag {tag} is already published at HEAD",
|
||||
"after_local_tag_commit": head_commit,
|
||||
"after_remote_tag_commit": head_commit,
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
created = False
|
||||
if not local_commit:
|
||||
create_result = run(create_command, cwd=path)
|
||||
if create_result.returncode != 0:
|
||||
results.append(
|
||||
{
|
||||
**row,
|
||||
"status": "failed",
|
||||
"detail": f"could not create annotated tag {tag}",
|
||||
"returncode": create_result.returncode,
|
||||
"stdout": compact_output(create_result.stdout),
|
||||
"stderr": compact_output(create_result.stderr),
|
||||
}
|
||||
)
|
||||
continue
|
||||
created = True
|
||||
|
||||
if not push:
|
||||
results.append(
|
||||
{
|
||||
**row,
|
||||
"status": "tagged" if created else "noop",
|
||||
"detail": f"created annotated tag {tag} at HEAD" if created else f"annotated tag {tag} already exists at HEAD",
|
||||
"after_local_tag_commit": head_commit,
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
publish_result = run(publish_command, cwd=path)
|
||||
if publish_result.returncode != 0:
|
||||
results.append(
|
||||
{
|
||||
**row,
|
||||
"status": "failed",
|
||||
"detail": f"created local tag {tag}, but atomic branch and tag publication failed" if created else f"atomic branch and tag publication failed for {tag}",
|
||||
"returncode": publish_result.returncode,
|
||||
"after_local_tag_commit": head_commit,
|
||||
"stdout": compact_output(publish_result.stdout),
|
||||
"stderr": compact_output(publish_result.stderr),
|
||||
}
|
||||
)
|
||||
continue
|
||||
after_local_object = git_text(path, "rev-parse", "--verify", f"refs/tags/{tag}")
|
||||
after_remote = remote_tag_commit(path, remote=remote, tag=tag)
|
||||
if (
|
||||
after_remote.error
|
||||
or not after_remote.annotated
|
||||
or after_remote.commit != head_commit
|
||||
or after_remote.tag_object != after_local_object
|
||||
):
|
||||
verification_detail = after_remote.error or "remote tag did not resolve to the published annotated tag object at HEAD"
|
||||
results.append(
|
||||
{
|
||||
**row,
|
||||
"status": "failed",
|
||||
"detail": f"Git push returned success, but the remote release-tag postcondition failed: {verification_detail}",
|
||||
"returncode": publish_result.returncode,
|
||||
"after_local_tag_commit": head_commit,
|
||||
"after_local_tag_object": after_local_object,
|
||||
"after_remote_tag_commit": after_remote.commit,
|
||||
"after_remote_tag_object": after_remote.tag_object,
|
||||
"stdout": compact_output(publish_result.stdout),
|
||||
"stderr": compact_output(publish_result.stderr),
|
||||
}
|
||||
)
|
||||
continue
|
||||
results.append(
|
||||
{
|
||||
**row,
|
||||
"status": "published",
|
||||
"detail": f"published branch {snapshot.branch} and immutable tag {tag} atomically to {remote}",
|
||||
"returncode": publish_result.returncode,
|
||||
"after_local_tag_commit": head_commit,
|
||||
"after_remote_tag_commit": head_commit,
|
||||
"after_local_tag_object": after_local_object,
|
||||
"after_remote_tag_object": after_remote.tag_object,
|
||||
"stdout": compact_output(publish_result.stdout),
|
||||
"stderr": compact_output(publish_result.stderr),
|
||||
}
|
||||
)
|
||||
|
||||
if any(item["status"] in {"blocked", "failed"} for item in results):
|
||||
status = "blocked" if not apply else "partial"
|
||||
elif any(item["status"] == "published" for item in results):
|
||||
status = "published"
|
||||
elif any(item["status"] == "tagged" for item in results):
|
||||
status = "tagged"
|
||||
elif any(item["status"] == "planned" for item in results):
|
||||
status = "planned"
|
||||
else:
|
||||
status = "noop"
|
||||
return {"status": status, "apply": apply, "push": push, "remote": remote, "repositories": results}
|
||||
|
||||
|
||||
def normalize_version(value: str | None) -> str:
|
||||
normalized = (value or "").strip().removeprefix("v")
|
||||
return normalized if _RELEASE_VERSION.fullmatch(normalized) else ""
|
||||
|
||||
|
||||
def basic_blocker(*, snapshot: RepositorySnapshot, version: str) -> str | None:
|
||||
if not snapshot.exists or not snapshot.is_git:
|
||||
return "repository is missing or is not a Git repository"
|
||||
if snapshot.safe_directory_required:
|
||||
return "Git safe.directory approval is required"
|
||||
if not snapshot.has_head:
|
||||
return "repository has no commit to tag"
|
||||
if not snapshot.branch:
|
||||
return "repository is not on a named branch"
|
||||
if snapshot.behind:
|
||||
return f"repository is behind {snapshot.upstream}"
|
||||
if snapshot.dirty_entries:
|
||||
return "worktree is not clean; commit or discard the reviewed changes before tagging"
|
||||
if not version:
|
||||
return "a valid target version is required"
|
||||
return None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RemoteTagResult:
|
||||
commit: str | None = None
|
||||
tag_object: str | None = None
|
||||
annotated: bool = False
|
||||
error: str | None = None
|
||||
|
||||
|
||||
def ref_commit(path: Path, ref: str) -> str | None:
|
||||
value = git_text(path, "rev-parse", "--verify", f"{ref}^{{commit}}")
|
||||
return value or None
|
||||
|
||||
|
||||
def remote_tag_commit(
|
||||
path: Path,
|
||||
*,
|
||||
remote: str,
|
||||
tag: str,
|
||||
timeout: int = 15,
|
||||
env: dict[str, str] | None = None,
|
||||
) -> RemoteTagResult:
|
||||
result = run(
|
||||
("git", "ls-remote", "--tags", remote, f"refs/tags/{tag}", f"refs/tags/{tag}^{{}}"),
|
||||
cwd=path,
|
||||
timeout=timeout,
|
||||
env=env,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
detail = compact_output(result.stderr) or compact_output(result.stdout) or "unknown Git error"
|
||||
return RemoteTagResult(error=f"could not inspect immutable tag {tag} on {remote}: {detail}")
|
||||
refs = {}
|
||||
for line in result.stdout.splitlines():
|
||||
commit, separator, ref = line.partition("\t")
|
||||
if separator:
|
||||
refs[ref] = commit
|
||||
peeled = refs.get(f"refs/tags/{tag}^{{}}")
|
||||
tag_object = refs.get(f"refs/tags/{tag}")
|
||||
return RemoteTagResult(commit=peeled or tag_object, tag_object=tag_object, annotated=peeled is not None)
|
||||
|
||||
|
||||
def preview_detail(*, tag: str, local_commit: str | None, remote_commit: str | None, push: bool) -> str:
|
||||
if remote_commit:
|
||||
return f"immutable tag {tag} is already published at HEAD"
|
||||
if local_commit and push:
|
||||
return f"existing local tag {tag} will be published with the current branch"
|
||||
if local_commit:
|
||||
return f"annotated tag {tag} already exists at HEAD"
|
||||
if push:
|
||||
return f"annotated tag {tag} will be created and published atomically with the current branch"
|
||||
return f"annotated tag {tag} will be created at HEAD"
|
||||
|
||||
|
||||
def run(
|
||||
command: tuple[str, ...],
|
||||
*,
|
||||
cwd: Path,
|
||||
timeout: int = 120,
|
||||
env: dict[str, str] | None = None,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
try:
|
||||
return subprocess.run(
|
||||
command,
|
||||
cwd=cwd,
|
||||
check=False,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=timeout,
|
||||
env=env,
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
return subprocess.CompletedProcess(command, 124, exc.stdout or "", exc.stderr or "Git command timed out")
|
||||
@@ -19,6 +19,11 @@ from .contracts import collect_contracts
|
||||
from .git_state import collect_repository_snapshot
|
||||
from .model import CatalogEntryChange, SelectiveCatalogCandidate
|
||||
from .module_directory import write_module_directory
|
||||
from .source_provenance import (
|
||||
catalog_source_selection,
|
||||
selected_source_provenance,
|
||||
source_tag_provenance_issues,
|
||||
)
|
||||
from .version_alignment import selected_repository_version_issues
|
||||
from .workspace import load_repository_specs, resolve_workspace_root, website_root
|
||||
|
||||
@@ -35,6 +40,7 @@ def build_selective_catalog_candidate(
|
||||
signing_keys: tuple[str, ...] = (),
|
||||
public_base_url: str = DEFAULT_PUBLIC_BASE_URL,
|
||||
repository_base: str = GITEA_BASE,
|
||||
source_remote: str = "origin",
|
||||
expires_days: int = 90,
|
||||
sequence: int | None = None,
|
||||
check_public: bool = True,
|
||||
@@ -48,6 +54,15 @@ def build_selective_catalog_candidate(
|
||||
|
||||
workspace = resolve_workspace_root(workspace_root)
|
||||
enforce_selected_version_alignment(repo_versions=repo_versions, workspace=workspace)
|
||||
enforce_selected_source_provenance(
|
||||
repo_versions=repo_versions,
|
||||
workspace=workspace,
|
||||
remote=source_remote,
|
||||
)
|
||||
selected_provenance = selected_source_provenance(
|
||||
repo_versions=repo_versions,
|
||||
workspace=workspace,
|
||||
)
|
||||
web_root = website_root(workspace)
|
||||
source_catalog = resolve_base_catalog(base_catalog=base_catalog, web_root=web_root, channel=channel)
|
||||
payload = read_catalog(source_catalog)
|
||||
@@ -66,7 +81,15 @@ def build_selective_catalog_candidate(
|
||||
release = {}
|
||||
release["catalog_url"] = f"{public_base_url.rstrip('/')}/catalogs/v1/channels/{channel}.json"
|
||||
release["keyring_url"] = f"{public_base_url.rstrip('/')}/catalogs/v1/keyring.json"
|
||||
release["selected_units"] = [{"repo": repo, "version": version, "tag": f"v{version.removeprefix('v')}"} for repo, version in sorted(repo_versions.items())]
|
||||
release["selected_units"] = [
|
||||
{
|
||||
"repo": repo,
|
||||
"version": version,
|
||||
"tag": f"v{version.removeprefix('v')}",
|
||||
**selected_provenance[repo],
|
||||
}
|
||||
for repo, version in sorted(repo_versions.items())
|
||||
]
|
||||
candidate["release"] = release
|
||||
|
||||
repo_contracts = contracts_by_repo(workspace)
|
||||
@@ -76,6 +99,11 @@ def build_selective_catalog_candidate(
|
||||
repo_contracts=repo_contracts,
|
||||
repository_base=repository_base.rstrip("/"),
|
||||
)
|
||||
enforce_complete_catalog_source_provenance(
|
||||
payload=candidate,
|
||||
workspace=workspace,
|
||||
remote=source_remote,
|
||||
)
|
||||
|
||||
output_root = resolve_output_dir(output_dir=output_dir, channel=channel, generated_at=generated_at)
|
||||
catalog_path = output_root / "channels" / f"{channel}.json"
|
||||
@@ -175,6 +203,50 @@ def enforce_selected_version_alignment(*, repo_versions: dict[str, str], workspa
|
||||
raise ValueError("Version alignment gate failed: " + "; ".join(failures))
|
||||
|
||||
|
||||
def enforce_selected_source_provenance(
|
||||
*,
|
||||
repo_versions: dict[str, str],
|
||||
workspace: Path,
|
||||
remote: str = "origin",
|
||||
) -> None:
|
||||
failures = source_tag_provenance_issues(
|
||||
repo_versions=repo_versions,
|
||||
workspace=workspace,
|
||||
remote=remote,
|
||||
require_head_repos=repo_versions,
|
||||
)
|
||||
if failures:
|
||||
raise ValueError(
|
||||
"Source tag provenance gate failed: "
|
||||
+ "; ".join(issue.describe() for issue in failures)
|
||||
)
|
||||
|
||||
|
||||
def enforce_complete_catalog_source_provenance(
|
||||
*,
|
||||
payload: object,
|
||||
workspace: Path,
|
||||
remote: str = "origin",
|
||||
) -> None:
|
||||
selection = catalog_source_selection(payload)
|
||||
failures = [*selection.issues]
|
||||
failures.extend(
|
||||
source_tag_provenance_issues(
|
||||
repo_versions=selection.all_versions,
|
||||
workspace=workspace,
|
||||
remote=remote,
|
||||
require_head_repos=selection.selected_versions,
|
||||
expected_commits=selection.selected_commits,
|
||||
expected_tag_objects=selection.selected_tag_objects,
|
||||
)
|
||||
)
|
||||
if failures:
|
||||
raise ValueError(
|
||||
"Complete catalog source provenance gate failed: "
|
||||
+ "; ".join(issue.describe() for issue in failures)
|
||||
)
|
||||
|
||||
|
||||
def resolve_base_catalog(*, base_catalog: Path | str | None, web_root: Path, channel: str) -> Path | str:
|
||||
if base_catalog is not None:
|
||||
value = str(base_catalog)
|
||||
|
||||
@@ -126,6 +126,7 @@ def compatibility_issues(dashboard: ReleaseDashboard) -> tuple[CompatibilityIssu
|
||||
|
||||
def dry_run_steps(*, units: tuple[ReleasePlanUnit, ...], dashboard: ReleaseDashboard, channel: str) -> tuple[ReleasePlanStep, ...]:
|
||||
steps: list[ReleasePlanStep] = []
|
||||
snapshots = {repo.spec.name: repo for repo in dashboard.repositories}
|
||||
for unit in units:
|
||||
steps.append(
|
||||
ReleasePlanStep(
|
||||
@@ -150,17 +151,19 @@ def dry_run_steps(*, units: tuple[ReleasePlanUnit, ...], dashboard: ReleaseDashb
|
||||
status="needs-executor",
|
||||
)
|
||||
)
|
||||
steps.append(
|
||||
ReleasePlanStep(
|
||||
id=f"{unit.repo}:commit",
|
||||
title=f"Commit {unit.repo} release changes",
|
||||
detail="Commit reviewed changes for this release unit only.",
|
||||
command=f"git add -A && git commit -m {shlex.quote(f'Release {unit.repo} {unit.target_tag}')}",
|
||||
cwd=f"{dashboard.workspace_root}/{unit.repo}",
|
||||
mutating=True,
|
||||
repo=unit.repo,
|
||||
snapshot = snapshots.get(unit.repo)
|
||||
if unit.current_version != unit.target_version or (snapshot is not None and snapshot.dirty):
|
||||
steps.append(
|
||||
ReleasePlanStep(
|
||||
id=f"{unit.repo}:commit",
|
||||
title=f"Commit {unit.repo} release changes",
|
||||
detail="Commit reviewed changes for this release unit only.",
|
||||
command=f"git add -A && git commit -m {shlex.quote(f'Release {unit.repo} {unit.target_tag}')}",
|
||||
cwd=f"{dashboard.workspace_root}/{unit.repo}",
|
||||
mutating=True,
|
||||
repo=unit.repo,
|
||||
)
|
||||
)
|
||||
)
|
||||
steps.append(
|
||||
ReleasePlanStep(
|
||||
id=f"{unit.repo}:tag",
|
||||
@@ -229,10 +232,16 @@ def dry_run_steps(*, units: tuple[ReleasePlanUnit, ...], dashboard: ReleaseDashb
|
||||
ReleasePlanStep(
|
||||
id="catalog:validate-sign-publish",
|
||||
title=f"Validate, sign, and publish {channel}",
|
||||
detail="The candidate writer validates and signs locally. Publishing to the website repo remains a separate apply step.",
|
||||
command="Review the candidate under runtime/release-candidates before publishing it to the website repository.",
|
||||
detail=(
|
||||
"The candidate writer validates and signs locally. Preview and guarded apply/tag/push executors "
|
||||
"are available in the Signed Website Catalog panel and the release-catalog CLI."
|
||||
),
|
||||
command=(
|
||||
"tools/release/release-catalog.py publish-candidate "
|
||||
f"--candidate-dir \"$CANDIDATE_DIR\" --channel {shlex.quote(channel)}"
|
||||
),
|
||||
cwd=dashboard.meta_root,
|
||||
status="needs-publish-executor",
|
||||
status="planned",
|
||||
)
|
||||
)
|
||||
return tuple(steps)
|
||||
|
||||
438
tools/release/govoplan_release/source_provenance.py
Normal file
438
tools/release/govoplan_release/source_provenance.py
Normal file
@@ -0,0 +1,438 @@
|
||||
"""Fail-closed provenance checks for catalog source release tags."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import tomllib
|
||||
from typing import Iterable
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from .git_state import collect_repository_snapshot, git_text
|
||||
from .repository_tag import RemoteTagResult, ref_commit, remote_tag_commit
|
||||
from .version_alignment import repository_version_issues
|
||||
from .workspace import load_repository_specs, resolve_repo_path
|
||||
|
||||
|
||||
_CATALOG_PYTHON_REF = re.compile(
|
||||
r"/(?P<repo>govoplan-[a-z0-9-]+)\.git@v(?P<version>[^\s;]+)$"
|
||||
)
|
||||
_VERSION = re.compile(r"^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SourceTagProvenanceIssue:
|
||||
repo: str
|
||||
tag: str
|
||||
message: str
|
||||
|
||||
def describe(self) -> str:
|
||||
return f"{self.repo} {self.tag}: {self.message}"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CatalogSourceSelection:
|
||||
all_versions: dict[str, str]
|
||||
selected_versions: dict[str, str]
|
||||
selected_commits: dict[str, str]
|
||||
selected_tag_objects: dict[str, str]
|
||||
issues: tuple[SourceTagProvenanceIssue, ...] = ()
|
||||
|
||||
|
||||
def catalog_source_selection(payload: object) -> CatalogSourceSelection:
|
||||
"""Extract all immutable Python source refs and the explicitly selected units."""
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
return CatalogSourceSelection(
|
||||
all_versions={},
|
||||
selected_versions={},
|
||||
selected_commits={},
|
||||
selected_tag_objects={},
|
||||
issues=(SourceTagProvenanceIssue("catalog", "", "catalog must be a JSON object"),),
|
||||
)
|
||||
|
||||
versions: dict[str, str] = {}
|
||||
issues: list[SourceTagProvenanceIssue] = []
|
||||
entries: list[tuple[str, object]] = [("core_release", payload.get("core_release"))]
|
||||
modules = payload.get("modules")
|
||||
if isinstance(modules, list):
|
||||
entries.extend((f"modules[{index}]", entry) for index, entry in enumerate(modules))
|
||||
|
||||
for source, raw_entry in entries:
|
||||
if not isinstance(raw_entry, dict):
|
||||
continue
|
||||
python_ref = raw_entry.get("python_ref")
|
||||
match = _CATALOG_PYTHON_REF.search(python_ref) if isinstance(python_ref, str) else None
|
||||
if match is None:
|
||||
continue # The catalog version-alignment gate reports malformed refs.
|
||||
repo = match.group("repo")
|
||||
version = match.group("version").removeprefix("v")
|
||||
previous = versions.setdefault(repo, version)
|
||||
if previous != version:
|
||||
issues.append(
|
||||
SourceTagProvenanceIssue(
|
||||
repo,
|
||||
f"v{version}",
|
||||
f"{source} conflicts with the catalog's earlier v{previous} source ref",
|
||||
)
|
||||
)
|
||||
|
||||
release = payload.get("release")
|
||||
selected_units = release.get("selected_units") if isinstance(release, dict) else None
|
||||
selected: dict[str, str] = {}
|
||||
selected_commits: dict[str, str] = {}
|
||||
selected_tag_objects: dict[str, str] = {}
|
||||
if not isinstance(selected_units, list) or not selected_units:
|
||||
issues.append(
|
||||
SourceTagProvenanceIssue(
|
||||
"catalog",
|
||||
"",
|
||||
"release.selected_units is missing or empty; selected source provenance cannot be established",
|
||||
)
|
||||
)
|
||||
else:
|
||||
for unit in selected_units:
|
||||
if not isinstance(unit, dict):
|
||||
continue
|
||||
repo = unit.get("repo")
|
||||
version = unit.get("version")
|
||||
if isinstance(repo, str) and isinstance(version, str):
|
||||
selected[repo] = version.removeprefix("v")
|
||||
commit = unit.get("commit_sha")
|
||||
tag_object = unit.get("tag_object_sha")
|
||||
if isinstance(commit, str) and re.fullmatch(r"[0-9a-fA-F]{40}|[0-9a-fA-F]{64}", commit):
|
||||
selected_commits[repo] = commit.lower()
|
||||
else:
|
||||
issues.append(
|
||||
SourceTagProvenanceIssue(repo, f"v{version.removeprefix('v')}", "selected unit has no valid commit_sha provenance")
|
||||
)
|
||||
if isinstance(tag_object, str) and re.fullmatch(r"[0-9a-fA-F]{40}|[0-9a-fA-F]{64}", tag_object):
|
||||
selected_tag_objects[repo] = tag_object.lower()
|
||||
else:
|
||||
issues.append(
|
||||
SourceTagProvenanceIssue(repo, f"v{version.removeprefix('v')}", "selected unit has no valid tag_object_sha provenance")
|
||||
)
|
||||
|
||||
return CatalogSourceSelection(
|
||||
all_versions=versions,
|
||||
selected_versions=selected,
|
||||
selected_commits=selected_commits,
|
||||
selected_tag_objects=selected_tag_objects,
|
||||
issues=tuple(issues),
|
||||
)
|
||||
|
||||
|
||||
def source_tag_provenance_issues(
|
||||
*,
|
||||
repo_versions: dict[str, str],
|
||||
workspace: Path,
|
||||
remote: str = "origin",
|
||||
require_head_repos: Iterable[str] = (),
|
||||
expected_commits: dict[str, str] | None = None,
|
||||
expected_tag_objects: dict[str, str] | None = None,
|
||||
) -> tuple[SourceTagProvenanceIssue, ...]:
|
||||
"""Verify immutable local/remote tags and selected clean source HEADs.
|
||||
|
||||
Every requested ref must be an annotated tag in the local object store and
|
||||
on the configured remote, and both tag objects must peel to the same commit.
|
||||
Repositories in ``require_head_repos`` additionally have to be clean,
|
||||
version-aligned worktrees whose requested tag peels to the current HEAD.
|
||||
"""
|
||||
|
||||
remote = remote.strip() or "origin"
|
||||
require_head = set(require_head_repos)
|
||||
expected_commits = expected_commits or {}
|
||||
expected_tag_objects = expected_tag_objects or {}
|
||||
requested = sorted(repo_versions.items())
|
||||
specs = {spec.name: spec for spec in load_repository_specs(include_website=False)}
|
||||
issues: list[SourceTagProvenanceIssue] = []
|
||||
|
||||
for repo, requested_version in requested:
|
||||
version = requested_version.removeprefix("v").strip()
|
||||
tag = f"v{version}"
|
||||
spec = specs.get(repo)
|
||||
if spec is None:
|
||||
issues.append(SourceTagProvenanceIssue(repo, tag, "repository is not registered in repositories.json"))
|
||||
continue
|
||||
if not _VERSION.fullmatch(version):
|
||||
issues.append(SourceTagProvenanceIssue(repo, tag, "requested version is not a valid release version"))
|
||||
continue
|
||||
|
||||
path = resolve_repo_path(spec, workspace)
|
||||
snapshot = collect_repository_snapshot(spec, workspace_root=workspace, target_tag=tag, online=False)
|
||||
if not snapshot.exists or not snapshot.is_git:
|
||||
issues.append(SourceTagProvenanceIssue(repo, tag, "local source repository is missing or is not a Git repository"))
|
||||
continue
|
||||
if snapshot.safe_directory_required:
|
||||
issues.append(SourceTagProvenanceIssue(repo, tag, "Git safe.directory approval is required"))
|
||||
continue
|
||||
if not snapshot.has_head:
|
||||
issues.append(SourceTagProvenanceIssue(repo, tag, "local source repository has no HEAD commit"))
|
||||
continue
|
||||
|
||||
head_commit = git_text(path, "rev-parse", "HEAD")
|
||||
local_commit = ref_commit(path, f"refs/tags/{tag}")
|
||||
local_object = git_text(path, "rev-parse", "--verify", f"refs/tags/{tag}") if local_commit else None
|
||||
local_type = git_text(path, "cat-file", "-t", f"refs/tags/{tag}") if local_commit else None
|
||||
if not local_commit:
|
||||
issues.append(SourceTagProvenanceIssue(repo, tag, "annotated tag is missing from the local source repository"))
|
||||
elif local_type != "tag":
|
||||
issues.append(SourceTagProvenanceIssue(repo, tag, "local release tag is lightweight; an annotated tag is required"))
|
||||
|
||||
remote_url = git_text(path, "remote", "get-url", remote)
|
||||
if remote_url == "":
|
||||
issues.append(SourceTagProvenanceIssue(repo, tag, f"configured Git remote {remote!r} does not exist"))
|
||||
remote_result = None
|
||||
else:
|
||||
remote_result = inspect_remote_tag(path=path, remote=remote, remote_url=remote_url, tag=tag)
|
||||
if remote_result.error:
|
||||
issues.append(SourceTagProvenanceIssue(repo, tag, remote_result.error))
|
||||
elif not remote_result.commit:
|
||||
issues.append(SourceTagProvenanceIssue(repo, tag, f"annotated tag is missing from remote {remote!r}"))
|
||||
elif not remote_result.annotated:
|
||||
issues.append(SourceTagProvenanceIssue(repo, tag, f"remote {remote!r} release tag is lightweight; an annotated tag is required"))
|
||||
|
||||
remote_commit = remote_result.commit if remote_result is not None and not remote_result.error else None
|
||||
remote_annotated = remote_result.annotated if remote_result is not None else False
|
||||
if local_commit and local_type == "tag" and remote_commit and remote_annotated and local_commit != remote_commit:
|
||||
issues.append(
|
||||
SourceTagProvenanceIssue(
|
||||
repo,
|
||||
tag,
|
||||
f"local tag peels to {local_commit[:12]}, but remote {remote!r} peels to {remote_commit[:12]}",
|
||||
)
|
||||
)
|
||||
if (
|
||||
local_object
|
||||
and local_type == "tag"
|
||||
and remote_result is not None
|
||||
and remote_result.annotated
|
||||
and remote_result.tag_object
|
||||
and local_object != remote_result.tag_object
|
||||
):
|
||||
issues.append(
|
||||
SourceTagProvenanceIssue(
|
||||
repo,
|
||||
tag,
|
||||
"local and remote annotated tag objects differ; the release annotation provenance is inconsistent",
|
||||
)
|
||||
)
|
||||
|
||||
expected_commit = expected_commits.get(repo)
|
||||
if expected_commit and local_commit and local_commit.lower() != expected_commit.lower():
|
||||
issues.append(
|
||||
SourceTagProvenanceIssue(
|
||||
repo,
|
||||
tag,
|
||||
f"local tag no longer matches signed commit_sha {expected_commit}",
|
||||
)
|
||||
)
|
||||
expected_object = expected_tag_objects.get(repo)
|
||||
if expected_object and local_object and local_object.lower() != expected_object.lower():
|
||||
issues.append(
|
||||
SourceTagProvenanceIssue(
|
||||
repo,
|
||||
tag,
|
||||
f"local tag no longer matches signed tag_object_sha {expected_object}",
|
||||
)
|
||||
)
|
||||
|
||||
issues.extend(tagged_source_version_issues(path=path, repo=repo, tag=tag, expected_version=version))
|
||||
|
||||
if repo in require_head:
|
||||
if not snapshot.branch:
|
||||
issues.append(SourceTagProvenanceIssue(repo, tag, "selected source HEAD is detached; a named branch is required"))
|
||||
if snapshot.dirty_entries:
|
||||
issues.append(SourceTagProvenanceIssue(repo, tag, "selected source worktree is not clean"))
|
||||
if snapshot.behind:
|
||||
issues.append(SourceTagProvenanceIssue(repo, tag, f"selected source branch is behind {snapshot.upstream}"))
|
||||
for issue in repository_version_issues(path, expected_version=version):
|
||||
issues.append(
|
||||
SourceTagProvenanceIssue(
|
||||
repo,
|
||||
tag,
|
||||
f"selected source version alignment failed: {issue.source}={issue.actual!r}, expected {issue.expected!r}",
|
||||
)
|
||||
)
|
||||
if local_commit and local_commit != head_commit:
|
||||
issues.append(
|
||||
SourceTagProvenanceIssue(
|
||||
repo,
|
||||
tag,
|
||||
f"local tag peels to {local_commit[:12]}, not selected HEAD {head_commit[:12]}",
|
||||
)
|
||||
)
|
||||
if remote_commit and remote_commit != head_commit:
|
||||
issues.append(
|
||||
SourceTagProvenanceIssue(
|
||||
repo,
|
||||
tag,
|
||||
f"remote tag peels to {remote_commit[:12]}, not selected HEAD {head_commit[:12]}",
|
||||
)
|
||||
)
|
||||
|
||||
return tuple(_deduplicate(issues))
|
||||
|
||||
|
||||
def selected_source_provenance(
|
||||
*,
|
||||
repo_versions: dict[str, str],
|
||||
workspace: Path,
|
||||
) -> dict[str, dict[str, str]]:
|
||||
"""Return immutable source object identifiers after the provenance gate passes."""
|
||||
|
||||
specs = {spec.name: spec for spec in load_repository_specs(include_website=False)}
|
||||
result: dict[str, dict[str, str]] = {}
|
||||
for repo, requested_version in sorted(repo_versions.items()):
|
||||
spec = specs[repo]
|
||||
path = resolve_repo_path(spec, workspace)
|
||||
tag = f"v{requested_version.removeprefix('v')}"
|
||||
result[repo] = {
|
||||
"commit_sha": git_text(path, "rev-parse", "--verify", f"refs/tags/{tag}^{{commit}}"),
|
||||
"tag_object_sha": git_text(path, "rev-parse", "--verify", f"refs/tags/{tag}"),
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def inspect_remote_tag(*, path: Path, remote: str, remote_url: str, tag: str) -> RemoteTagResult:
|
||||
"""Inspect refs over same-host HTTPS when possible, then fall back to the configured remote."""
|
||||
|
||||
environment = {
|
||||
**os.environ,
|
||||
"GIT_TERMINAL_PROMPT": "0",
|
||||
"GIT_SSH_COMMAND": os.environ.get(
|
||||
"GIT_SSH_COMMAND",
|
||||
"ssh -o BatchMode=yes -o ConnectTimeout=8",
|
||||
),
|
||||
}
|
||||
inspection_remote = read_only_https_remote(remote_url)
|
||||
if inspection_remote:
|
||||
result = remote_tag_commit(
|
||||
path,
|
||||
remote=inspection_remote,
|
||||
tag=tag,
|
||||
timeout=5,
|
||||
env=environment,
|
||||
)
|
||||
if not result.error:
|
||||
return result
|
||||
return remote_tag_commit(
|
||||
path,
|
||||
remote=remote,
|
||||
tag=tag,
|
||||
timeout=12,
|
||||
env=environment,
|
||||
)
|
||||
|
||||
|
||||
def read_only_https_remote(remote_url: str) -> str | None:
|
||||
"""Derive the equivalent same-host HTTPS Git URL for a conventional SSH remote."""
|
||||
|
||||
value = remote_url.strip()
|
||||
if value.startswith(("https://", "http://")):
|
||||
return value
|
||||
if value.startswith("ssh://"):
|
||||
parsed = urlsplit(value)
|
||||
if parsed.hostname and parsed.port in {None, 22} and parsed.path:
|
||||
return f"https://{parsed.hostname}{parsed.path}"
|
||||
return None
|
||||
match = re.fullmatch(r"(?:[^@/:]+@)?([^/:]+):(.+)", value)
|
||||
if match:
|
||||
return f"https://{match.group(1)}/{match.group(2).lstrip('/')}"
|
||||
return None
|
||||
|
||||
|
||||
def tagged_source_version_issues(
|
||||
*,
|
||||
path: Path,
|
||||
repo: str,
|
||||
tag: str,
|
||||
expected_version: str,
|
||||
) -> tuple[SourceTagProvenanceIssue, ...]:
|
||||
"""Check version-bearing files from the tag without changing the worktree."""
|
||||
|
||||
if not ref_commit(path, f"refs/tags/{tag}"):
|
||||
return ()
|
||||
tree = _git(path, "ls-tree", "-r", "--name-only", tag)
|
||||
if tree.returncode != 0:
|
||||
return (SourceTagProvenanceIssue(repo, tag, "could not inspect the local tag's source tree"),)
|
||||
files = set(tree.stdout.splitlines())
|
||||
declarations: list[tuple[str, str | None]] = []
|
||||
|
||||
pyproject = _git_file(path, tag, "pyproject.toml")
|
||||
if pyproject is None:
|
||||
return (SourceTagProvenanceIssue(repo, tag, "tagged source has no pyproject.toml package metadata"),)
|
||||
try:
|
||||
parsed = tomllib.loads(pyproject)
|
||||
project = parsed.get("project")
|
||||
declarations.append(("pyproject.toml", project.get("version") if isinstance(project, dict) else None))
|
||||
except tomllib.TOMLDecodeError:
|
||||
declarations.append(("pyproject.toml", None))
|
||||
|
||||
for name in ("package.json", "webui/package.json", "webui/package.release.json"):
|
||||
if name not in files:
|
||||
continue
|
||||
declarations.append((name, _json_version(_git_file(path, tag, name))))
|
||||
|
||||
for name in sorted(files):
|
||||
if name.startswith("src/") and name.endswith("/backend/manifest.py"):
|
||||
text = _git_file(path, tag, name) or ""
|
||||
match = re.search(r'(?m)^\s*(?:version|MODULE_VERSION)\s*=\s*["\']([^"\']+)["\']', text)
|
||||
if match:
|
||||
declarations.append((name, match.group(1)))
|
||||
|
||||
return tuple(
|
||||
SourceTagProvenanceIssue(
|
||||
repo,
|
||||
tag,
|
||||
f"tagged {source} version is {actual!r}, expected {expected_version!r}",
|
||||
)
|
||||
for source, actual in declarations
|
||||
if actual != expected_version
|
||||
)
|
||||
|
||||
|
||||
def _json_version(value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
payload = json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
version = payload.get("version") if isinstance(payload, dict) else None
|
||||
return version if isinstance(version, str) else None
|
||||
|
||||
|
||||
def _git_file(path: Path, tag: str, name: str) -> str | None:
|
||||
result = _git(path, "show", f"{tag}:{name}")
|
||||
return result.stdout if result.returncode == 0 else None
|
||||
|
||||
|
||||
def _git(path: Path, *args: str) -> subprocess.CompletedProcess[str]:
|
||||
try:
|
||||
return subprocess.run(
|
||||
("git", *args),
|
||||
cwd=path,
|
||||
check=False,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=30,
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
return subprocess.CompletedProcess(("git", *args), 124, exc.stdout or "", exc.stderr or "Git command timed out")
|
||||
|
||||
|
||||
def _deduplicate(issues: list[SourceTagProvenanceIssue]) -> list[SourceTagProvenanceIssue]:
|
||||
result: list[SourceTagProvenanceIssue] = []
|
||||
seen: set[tuple[str, str, str]] = set()
|
||||
for issue in issues:
|
||||
key = (issue.repo, issue.tag, issue.message)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
result.append(issue)
|
||||
return result
|
||||
Reference in New Issue
Block a user