mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-10-19 05:50:41 +08:00
* feat(ci): detect breaking protobuf changes Compare the proto module against the PR or merge-group base and report Buf violations in Branch Checks. Add local reproduction and fixture coverage. Closes #3794 Signed-off-by: Mrunal Patel <mrunalp@gmail.com> * fix(ci): pin protobuf check container image Signed-off-by: Mrunal Patel <mrunalp@gmail.com> * fix(ci): qualify protobuf compatibility by release train Signed-off-by: Simon Scatton <sscatton@nvidia.com> * refactor(ci): reuse protobuf compatibility action Signed-off-by: Simon Scatton <sscatton@nvidia.com> * refactor(ci): run protobuf checks as a Nix app with one ref Signed-off-by: Simon Scatton <sscatton@nvidia.com> --------- Signed-off-by: Mrunal Patel <mrunalp@gmail.com> Signed-off-by: Simon Scatton <sscatton@nvidia.com> Co-authored-by: Mrunal Patel <mrunalp@gmail.com>
146 lines
5.2 KiB
Python
146 lines
5.2 KiB
Python
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
# SPDX-License-Identifier: Apache-2.0
|
|
|
|
"""Check protobuf compatibility under the current, tag-defined release train."""
|
|
|
|
import argparse
|
|
import io
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import tarfile
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
from release import _parse_prerelease_tag, _parse_semver_tag
|
|
|
|
|
|
def git(*args: str) -> str:
|
|
return subprocess.run(
|
|
["git", *args], check=True, capture_output=True, text=True
|
|
).stdout.strip()
|
|
|
|
|
|
def train_policy(ref: str, release: str | None) -> tuple[str, str, bool]:
|
|
tags = git("tag", "--merged", ref, "--list", "v*").splitlines()
|
|
stable = sorted(
|
|
(version, tag)
|
|
for tag in tags
|
|
if tag != release and (version := _parse_semver_tag(tag))
|
|
)
|
|
if release is None:
|
|
prereleases = sorted(
|
|
(version, tag) for tag in tags if (version := _parse_prerelease_tag(tag))
|
|
)
|
|
if not prereleases or (stable and prereleases[-1][0][:3] <= stable[-1][0]):
|
|
return "", "none", False
|
|
release = prereleases[-1][1]
|
|
|
|
if not stable:
|
|
raise ValueError("No previous stable release baseline is available.")
|
|
previous, baseline = stable[-1]
|
|
prerelease = _parse_prerelease_tag(release)
|
|
version = prerelease[:3] if prerelease else _parse_semver_tag(release)
|
|
if version is None or not release.startswith("v"):
|
|
raise ValueError(f"Invalid release tag: {release}")
|
|
if version <= previous:
|
|
raise ValueError(f"Release {release} must be newer than stable {baseline}.")
|
|
train = "v" + ".".join(map(str, version))
|
|
allows_breaks = version[:2] > previous[:2]
|
|
return baseline, f"{train} (latest stable: {baseline})", allows_breaks
|
|
|
|
|
|
def export_proto(ref: str, destination: Path) -> None:
|
|
archive = subprocess.check_output(
|
|
["git", "archive", ref, "--", "buf.yaml", "proto"]
|
|
)
|
|
with tarfile.open(fileobj=io.BytesIO(archive)) as snapshot:
|
|
snapshot.extractall(destination, filter="data")
|
|
|
|
|
|
def compare(candidate: str, baseline: str, allows_breaks: bool) -> int:
|
|
with tempfile.TemporaryDirectory(prefix="openshell-proto-") as directory:
|
|
root = Path(directory)
|
|
export_proto(baseline, root / "before")
|
|
export_proto(candidate, root / "after")
|
|
error_format = (
|
|
"github-actions" if os.environ.get("GITHUB_ACTIONS") == "true" else "text"
|
|
)
|
|
# Buf also returns 100 for compiler diagnostics. Validate both snapshots
|
|
# before treating that status from `breaking` as an allowed API change.
|
|
for snapshot in ("before", "after"):
|
|
subprocess.run(
|
|
[
|
|
"buf",
|
|
"build",
|
|
"proto",
|
|
"--error-format",
|
|
error_format,
|
|
"-o",
|
|
os.devnull,
|
|
],
|
|
cwd=root / snapshot,
|
|
check=True,
|
|
)
|
|
result = subprocess.run(
|
|
[
|
|
"buf",
|
|
"breaking",
|
|
"proto",
|
|
"--against",
|
|
str(root / "before" / "proto"),
|
|
"--config",
|
|
"buf.yaml",
|
|
"--error-format",
|
|
error_format,
|
|
],
|
|
cwd=root / "after",
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
diagnostics = result.stdout + result.stderr
|
|
if result.returncode == 100 and allows_breaks:
|
|
print(diagnostics.replace("::error ", "::warning "), end="")
|
|
print(
|
|
"Breaking changes allowed by the version increment; review migration guidance."
|
|
)
|
|
return 0
|
|
print(diagnostics, end="")
|
|
return result.returncode
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("ref", help="Target branch/commit, or release tag to qualify")
|
|
args = parser.parse_args()
|
|
ref = git(
|
|
"rev-parse", "--symbolic-full-name", "--verify", "--end-of-options", args.ref
|
|
)
|
|
candidate = git(
|
|
"rev-parse", "--verify", "--end-of-options", f"{args.ref}^{{commit}}"
|
|
)
|
|
release = ref.removeprefix("refs/tags/") if ref.startswith("refs/tags/") else None
|
|
stable, train, allows_breaks = train_policy(candidate, release)
|
|
baseline = f"refs/tags/{stable}" if release else candidate
|
|
if not release:
|
|
# merge-tree writes only Git objects, leaving HEAD and the worktree
|
|
# intact. Target-only additions are therefore not mistaken for deletions.
|
|
candidate = git("merge-tree", "--write-tree", baseline, "HEAD").splitlines()[0]
|
|
print(
|
|
f"Train: {train}; breaking changes {'allowed' if allows_breaks else 'forbidden'}"
|
|
)
|
|
print(f"Comparing {candidate} against {baseline}", flush=True)
|
|
return compare(candidate, baseline, allows_breaks)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
sys.exit(main())
|
|
except subprocess.CalledProcessError as error:
|
|
print(error.stderr or error.stdout or str(error), file=sys.stderr)
|
|
sys.exit(1)
|
|
except (OSError, ValueError, tarfile.TarError) as error:
|
|
print(error, file=sys.stderr)
|
|
sys.exit(1)
|