diff --git a/.github/scripts/tests/test_cua_driver_release_wiring.py b/.github/scripts/tests/test_cua_driver_release_wiring.py index bbd9ff4777..84e4c16571 100644 --- a/.github/scripts/tests/test_cua_driver_release_wiring.py +++ b/.github/scripts/tests/test_cua_driver_release_wiring.py @@ -111,6 +111,7 @@ def test_release_please_owns_driver_and_lume(self) -> None: self.assertIn('"component": "cua-driver-rs"', config) self.assertIn('"component": "lume"', config) self.assertIn("5c625bfb5d1ff62eadeeb3772007f7f66fdcf071", workflow) + self.assertIn("validate_release_please_tags.py --target HEAD", workflow) self.assertIn('-p cua-driver --precise "$DRIVER_VERSION"', workflow) self.assertIn( "gh pr list --state open --base main --limit 100 --json number", diff --git a/.github/scripts/tests/test_validate_release_please_tags.py b/.github/scripts/tests/test_validate_release_please_tags.py new file mode 100644 index 0000000000..c8fd505b6f --- /dev/null +++ b/.github/scripts/tests/test_validate_release_please_tags.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +import importlib.util +from pathlib import Path +import subprocess + +import pytest + +SCRIPT_PATH = Path(__file__).resolve().parents[1] / "validate_release_please_tags.py" +SPEC = importlib.util.spec_from_file_location("validate_release_please_tags", SCRIPT_PATH) +assert SPEC is not None and SPEC.loader is not None +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) +TagValidationError = MODULE.TagValidationError +package_tag = MODULE.package_tag +validate_tags = MODULE.validate_tags + + +CONFIG = { + "include-component-in-tag": True, + "include-v-in-tag": True, + "tag-separator": "-", + "packages": { + "libs/cua-driver": { + "component": "cua-driver-rs", + "release-type": "simple", + } + }, +} +MANIFEST = {"libs/cua-driver": "0.18.0"} + + +def run_git(repo: Path, *args: str) -> str: + return subprocess.run( + ["git", *args], cwd=repo, check=True, capture_output=True, text=True + ).stdout.strip() + + +@pytest.fixture +def repo(tmp_path: Path) -> Path: + run_git(tmp_path, "init", "-b", "main") + run_git(tmp_path, "config", "user.name", "Release Test") + run_git(tmp_path, "config", "user.email", "release-test@example.com") + (tmp_path / "file").write_text("first\n") + run_git(tmp_path, "add", "file") + run_git(tmp_path, "commit", "-m", "first") + return tmp_path + + +def test_package_tag_uses_manifest_tag_options() -> None: + assert package_tag(CONFIG, CONFIG["packages"]["libs/cua-driver"], "0.18.0") == ( + "cua-driver-rs-v0.18.0" + ) + + +def test_allows_missing_tag_for_newly_merged_release(repo: Path) -> None: + assert validate_tags(repo_root=repo, config=CONFIG, manifest=MANIFEST, target="HEAD") == [] + + +def test_accepts_manifest_tag_on_target_branch(repo: Path) -> None: + run_git(repo, "tag", "cua-driver-rs-v0.18.0") + (repo / "file").write_text("second\n") + run_git(repo, "commit", "-am", "second") + + assert validate_tags(repo_root=repo, config=CONFIG, manifest=MANIFEST, target="HEAD") == [ + "cua-driver-rs-v0.18.0" + ] + + +def test_rejects_manifest_tag_outside_target_branch(repo: Path) -> None: + base = run_git(repo, "rev-parse", "HEAD") + run_git(repo, "switch", "-c", "release-metadata") + (repo / "metadata").write_text("notes\n") + run_git(repo, "add", "metadata") + run_git(repo, "commit", "-m", "metadata") + run_git(repo, "tag", "cua-driver-rs-v0.18.0") + run_git(repo, "switch", "main") + (repo / "file").write_text("main\n") + run_git(repo, "commit", "-am", "main") + assert run_git(repo, "merge-base", "HEAD", "release-metadata") == base + + with pytest.raises(TagValidationError, match="is not an ancestor"): + validate_tags(repo_root=repo, config=CONFIG, manifest=MANIFEST, target="HEAD") diff --git a/.github/scripts/validate_release_please_tags.py b/.github/scripts/validate_release_please_tags.py new file mode 100644 index 0000000000..23f24783c6 --- /dev/null +++ b/.github/scripts/validate_release_please_tags.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +"""Reject Release Please manifest tags that are not on the target branch.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import subprocess +from typing import Any, Mapping, Sequence + + +class TagValidationError(RuntimeError): + """A released manifest version has an invalid Git tag.""" + + +def package_tag(config: Mapping[str, Any], package: Mapping[str, Any], version: str) -> str: + def option(name: str, default: Any) -> Any: + return package.get(name, config.get(name, default)) + + component = package.get("component", package.get("package-name")) + if option("include-component-in-tag", False) and not component: + raise TagValidationError("component tag requested without a component or package name") + + prefix = ( + str(component) + str(option("tag-separator", "-")) + if option("include-component-in-tag", False) + else "" + ) + version_prefix = "v" if option("include-v-in-tag", True) else "" + return f"{prefix}{version_prefix}{version}" + + +def git(*args: str, cwd: Path, check: bool = True) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["git", *args], + cwd=cwd, + check=check, + capture_output=True, + text=True, + ) + + +def validate_tags( + *, + repo_root: Path, + config: Mapping[str, Any], + manifest: Mapping[str, Any], + target: str, +) -> list[str]: + packages = config.get("packages") + if not isinstance(packages, Mapping): + raise TagValidationError("release config must contain a packages object") + + checked: list[str] = [] + for path, package in packages.items(): + if path not in manifest: + raise TagValidationError(f"release manifest does not contain {path!r}") + if not isinstance(package, Mapping): + raise TagValidationError(f"release package config for {path!r} must be an object") + + tag = package_tag(config, package, str(manifest[path])) + tag_ref = f"refs/tags/{tag}^{{commit}}" + if git("rev-parse", "--verify", "--quiet", tag_ref, cwd=repo_root, check=False).returncode: + # Immediately after a release PR merges, its new manifest version has + # no tag yet. Release Please must be allowed to create that tag. + continue + if git( + "merge-base", "--is-ancestor", tag_ref, target, cwd=repo_root, check=False + ).returncode: + tag_sha = git("rev-parse", tag_ref, cwd=repo_root).stdout.strip() + target_sha = git("rev-parse", target, cwd=repo_root).stdout.strip() + raise TagValidationError( + f"manifest tag {tag} ({tag_sha}) for {path} is not an ancestor of " + f"{target} ({target_sha}); refusing to generate release history" + ) + checked.append(tag) + return checked + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repo-root", type=Path, default=Path.cwd()) + parser.add_argument("--config", type=Path, default=Path("release-please-config.json")) + parser.add_argument("--manifest", type=Path, default=Path(".release-please-manifest.json")) + parser.add_argument("--target", default="HEAD") + args = parser.parse_args(argv) + + try: + repo_root = args.repo_root.resolve() + config_path = args.config if args.config.is_absolute() else repo_root / args.config + manifest_path = args.manifest if args.manifest.is_absolute() else repo_root / args.manifest + checked = validate_tags( + repo_root=repo_root, + config=json.loads(config_path.read_text()), + manifest=json.loads(manifest_path.read_text()), + target=args.target, + ) + except (OSError, ValueError, subprocess.CalledProcessError, TagValidationError) as error: + print(f"release tag validation error: {error}") + return 1 + + if checked: + print(f"release manifest tags are on {args.target}: {', '.join(checked)}") + else: + print("release manifest tags do not exist yet; Release Please may create them") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index b32ddda676..06ba8b1d1a 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -61,6 +61,9 @@ jobs: echo "::error::Select cua-driver-rs or lume when forcing a bump type." exit 1 + - name: Reject manifest tags outside main history + run: python3 .github/scripts/validate_release_please_tags.py --target HEAD + - name: Resolve targeted release request id: request if: >-