From 25d1916b4b35ff37830e5a3303b21e4e4db6eac1 Mon Sep 17 00:00:00 2001 From: Herbert Damker <52109189+hdamker@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:02:35 +0200 Subject: [PATCH] refactor(ra): add sync_pr.py and the common-file sync to common_sync.py --- .../workflows/release-automation-reusable.yml | 22 +- release_automation/scripts/common_sync.py | 273 ++++++++++++- release_automation/scripts/git_operations.py | 48 ++- release_automation/scripts/github_client.py | 90 ++++- release_automation/scripts/sync_pr.py | 182 +++++++++ release_automation/tests/conftest.py | 10 + release_automation/tests/local_git.py | 55 +++ release_automation/tests/test_common_sync.py | 378 +++++++++++++++++- .../tests/test_git_operations.py | 53 +++ .../tests/test_github_client.py | 52 +++ release_automation/tests/test_sync_pr.py | 230 +++++++++++ .../tests/test_release_automation_workflow.py | 4 +- 12 files changed, 1349 insertions(+), 48 deletions(-) create mode 100644 release_automation/scripts/sync_pr.py create mode 100644 release_automation/tests/conftest.py create mode 100644 release_automation/tests/local_git.py create mode 100644 release_automation/tests/test_sync_pr.py diff --git a/.github/workflows/release-automation-reusable.yml b/.github/workflows/release-automation-reusable.yml index 10b59d24..bc335c19 100644 --- a/.github/workflows/release-automation-reusable.yml +++ b/.github/workflows/release-automation-reusable.yml @@ -77,7 +77,7 @@ jobs: meta_release: ${{ steps.state.outputs.meta_release }} common_cache_status: ${{ steps.cache-sync.outputs.common_cache_status }} common_cache_details: ${{ steps.cache-sync.outputs.common_cache_details }} - common_sync_pr_url: ${{ steps.find-sync-pr.outputs.common_sync_pr_url }} + common_sync_pr_url: ${{ steps.cache-sync.outputs.common_sync_pr_url }} allowed: ${{ steps.policy.outputs.allowed }} error_message: ${{ steps.policy.outputs.error_message }} base_context: ${{ steps.assemble.outputs.base_context }} @@ -199,24 +199,10 @@ jobs: env: API_REPO_PATH: ${{ github.workspace }}/_api_repo COMMONALITIES_RELEASE: ${{ steps.state.outputs.commonalities_release }} + ICM_RELEASE: ${{ steps.state.outputs.identity_consent_management_release }} + GITHUB_TOKEN: ${{ github.token }} run: python -m release_automation.scripts.common_sync - - name: Find existing sync PR - id: find-sync-pr - if: steps.cache-sync.outputs.common_cache_status == 'stale' - env: - GH_TOKEN: ${{ github.token }} - shell: bash - run: | - SYNC_PR_URL=$(gh pr list --state open --json headRefName,url \ - --jq '.[] | select(.headRefName | startswith("sync-common/")) | .url' 2>/dev/null | head -1 || true) - echo "common_sync_pr_url=$SYNC_PR_URL" >> "$GITHUB_OUTPUT" - if [ -n "$SYNC_PR_URL" ]; then - echo "Found existing sync PR: $SYNC_PR_URL" - else - echo "No existing sync PR found" - fi - # ── Command policy: Plan or Rejection, and the response to post ── - name: Apply Command Policy id: policy @@ -232,7 +218,7 @@ jobs: CONFIG_ERROR_TYPE: ${{ steps.state.outputs.config_error_type }} COMMON_CACHE_STATUS: ${{ steps.cache-sync.outputs.common_cache_status }} COMMON_CACHE_DETAILS: ${{ steps.cache-sync.outputs.common_cache_details }} - COMMON_SYNC_PR_URL: ${{ steps.find-sync-pr.outputs.common_sync_pr_url }} + COMMON_SYNC_PR_URL: ${{ steps.cache-sync.outputs.common_sync_pr_url }} GITHUB_TOKEN: ${{ github.token }} run: python -m release_automation.scripts.command_policy diff --git a/release_automation/scripts/common_sync.py b/release_automation/scripts/common_sync.py index 6a07a70f..281e89ab 100644 --- a/release_automation/scripts/common_sync.py +++ b/release_automation/scripts/common_sync.py @@ -3,28 +3,48 @@ Detection: compare an API repository's code/common/ cache with the Commonalities release it declares (tooling_lib.cache_sync). The plan job uses -the result to block /create-snapshot on a stale cache and to feed the drift -warning. Repair (rebuilding the cache and its sync PR) still runs inline in the -workflow. +the result to block /create-snapshot on a stale cache and to show the cache +state on the Release Issue. -This module depends only on tooling_lib.cache_sync; the release-state gate -stays with the caller. +Sync: copy artifacts/common/*.yaml of that release into code/common/, write +the sync manifest, rebuild the sync-common/ branch from main and +open (or reuse) its PR. -Usage (workflow step): +The interface takes a git client and a GitHub client and no release-automation +types, so the module can move to a repository-reconcile workflow; the +release-state gate stays with the caller. + +Usage (workflow step, detection): python3 -m release_automation.scripts.common_sync """ import os import re +import shutil import sys from dataclasses import dataclass from pathlib import Path +from typing import Dict, List, Tuple + +import yaml -from tooling_lib.cache_sync import SyncStatus, check_sync_status +from release_automation.scripts.git_operations import GitOperationsError +from tooling_lib.cache_sync import ( + COMMON_DIR, + MANIFEST_FILENAME, + SyncStatus, + check_sync_status, + git_blob_sha, +) # Releases before r4.2 have no sync manifest to check against. FIRST_CHECKED_RELEASE = (4, 2) +COMMONALITIES = "Commonalities" +ICM = "IdentityAndConsentManagement" +SYNC_BRANCH_PREFIX = "sync-common/" +PR_LABELS = ["common-sync", "automated"] + @dataclass(frozen=True) class CacheCheck: @@ -52,13 +72,36 @@ def describe(status: SyncStatus) -> str: return "; ".join(parts) if parts else "cache mismatch detected" -def detect(api_repo: Path, commonalities_release: str) -> CacheCheck: +def _manifest_repositories(api_repo: Path) -> List[str]: + try: + manifest = yaml.safe_load((api_repo / COMMON_DIR / MANIFEST_FILENAME).read_text(encoding="utf-8")) + return [s.get("repository") for s in manifest["sources"] if isinstance(s, dict)] + except (OSError, yaml.YAMLError, KeyError, TypeError): + return [] + + +def expected_releases(api_repo: Path, commonalities_release: str, icm_release: str = "") -> Dict[str, str]: + """ + Source releases code/common/ must match. + + The ICM release counts only once the manifest records ICM files: ICM + publishes no common artifacts today, so requiring it would leave every + cache stale with no common sync able to fix it. """ - Check code/common/ against the declared Commonalities release. + expected = {COMMONALITIES: commonalities_release} + if icm_release and ICM in _manifest_repositories(api_repo): + expected[ICM] = icm_release + return expected + + +def detect(api_repo: Path, commonalities_release: str, icm_release: str = "") -> CacheCheck: + """ + Check code/common/ against the declared Commonalities (and ICM) release. Args: api_repo: Checkout of the API repository (code/common/ is enough) commonalities_release: Declared release, e.g. "r4.2" + icm_release: Declared ICM release (see expected_releases) Returns: CacheCheck; empty status when the release is unparsable or before r4.2 @@ -71,7 +114,7 @@ def detect(api_repo: Path, commonalities_release: str) -> CacheCheck: print(f"commonalities_release {commonalities_release} < r4.2, skipping cache check") return CacheCheck() - status = check_sync_status(api_repo, {"Commonalities": commonalities_release}) + status = check_sync_status(api_repo, expected_releases(api_repo, commonalities_release, icm_release)) if status.in_sync: print(f"Common file cache is in sync with {commonalities_release}") return CacheCheck(status="in_sync") @@ -81,13 +124,219 @@ def detect(api_repo: Path, commonalities_release: str) -> CacheCheck: return CacheCheck(status="stale", details=details) -def main() -> int: +def sync_branch(release: str) -> str: + return f"{SYNC_BRANCH_PREFIX}{release}" + + +def find_sync_pr(gh, release: str) -> str: + """URL of the open sync PR for exactly this release's branch, or "".""" + found = gh.find_open_pr(sync_branch(release)) + return found[1] if found else "" + + +@dataclass(frozen=True) +class CommonSyncOutcome: + """ + Common sync result; status is created | updated | no_changes | failed. + + unexpected_files lists names under code/common/ the release does not + provide (left in place, named in the PR body). + """ + status: str + pr_url: str = "" + pr_number: str = "" + unexpected_files: Tuple[str, ...] = () + + +class CommonSyncError(Exception): + """A common sync step failed; the message is the ::error:: text.""" + + +def copy_common_files(source_dir: Path, api_repo: Path, release: str) -> Dict[str, str]: + """ + Copy the release's *.yaml files into code/common/ and write the manifest. + + Returns: + File name -> git blob SHA of each copied file + + Raises: + CommonSyncError: When the release has no artifacts/common or no YAML in it + """ + if not source_dir.is_dir(): + raise CommonSyncError(f"Commonalities artifacts/common not found at tag {release}") + source_files = sorted(p for p in source_dir.iterdir() if p.suffix == ".yaml") + if not source_files: + raise CommonSyncError(f"No YAML files in Commonalities artifacts/common at {release}") + + common_dir = api_repo / COMMON_DIR + common_dir.mkdir(parents=True, exist_ok=True) + files = {} + for src in source_files: + dest = common_dir / src.name + shutil.copy2(src, dest) + files[src.name] = git_blob_sha(dest.read_bytes()) + print(f"Synced: {src.name} ({files[src.name][:12]})") + + manifest = {"sources": [{"repository": COMMONALITIES, "release": release, "files": files}]} + (common_dir / MANIFEST_FILENAME).write_text( + yaml.dump(manifest, default_flow_style=False, sort_keys=False), encoding="utf-8" + ) + print(f"Wrote manifest: {common_dir / MANIFEST_FILENAME} ({len(files)} files)") + return files + + +def pr_body(release: str, unexpected: Tuple[str, ...] = ()) -> str: + body = ( + "> [!WARNING]\n" + "> This branch is owned by Release Automation and is rebuilt from `main` on every run.\n" + "> **Do not commit fixes to this branch** — any commits here are overwritten the next time the sync runs.\n" + "\n" + f"Automated sync of `code/common/` files from `camaraproject/Commonalities` at release tag `{release}`.\n" + "\n" + "This PR was created by release automation because the cached Commonalities files were out of sync " + "with the declared dependency in `release-plan.yaml`.\n" + "\n" + "**What changed:**\n" + f"- Updated files in `code/common/` to match Commonalities {release}\n" + "- Added/updated `.sync-manifest.yaml` with file integrity hashes\n" + "\n" + "**How to handle this PR:**\n" + "- **Validation green** — review and merge.\n" + "- **Validation red** — choose one:\n" + " - *(recommended)* fix the errors on `main`, then re-trigger the sync; this PR is rebuilt from " + "the new `main` HEAD with the corrected content.\n" + " - merge this PR with the errors and fix them on `main` afterwards.\n" + "\n" + "To re-trigger the sync, run the **CAMARA Release Automation** workflow from the repository's " + "**Actions** tab.\n" + "\n" + "`/create-snapshot` is blocked until common files are in sync." + ) + if unexpected: + names = ", ".join(f"`{name}`" for name in unexpected) + body += ( + f"\n\n**Not provided by Commonalities {release}:** {names} in `code/common/` — " + "remove them on `main` if they are no longer needed." + ) + return body + + +def _close_superseded_prs(gh, branch: str, release: str) -> None: + try: + prs = gh.list_open_prs() + except Exception as e: + print(f"Could not list open PRs: {e}") + return + for pr in prs: + if pr["head"].startswith(SYNC_BRANCH_PREFIX) and pr["head"] != branch: + print(f"Closing stale sync PR #{pr['number']}") + try: + gh.close_pr(pr["number"], comment=f"Superseded by sync to Commonalities {release}") + except Exception as e: + print(f"Could not close #{pr['number']}: {e}") + + +def sync_common(api_git, commonalities_git, gh, release: str, bot_name: str, bot_email: str) -> CommonSyncOutcome: + """ + Rebuild code/common/ from the Commonalities release and open or reuse its sync PR. + + Args: + api_git: Git client of the API repository; main is cloned into its work_dir + commonalities_git: Git client of camaraproject/Commonalities; the release + tag is cloned into its work_dir + gh: GitHub client of the API repository + release: Declared Commonalities release, e.g. "r4.3" + bot_name: Commit author and committer name + bot_email: Commit author and committer email + + Returns: + CommonSyncOutcome; every failed step is status "failed", nothing raises + """ + branch = sync_branch(release) + api_repo = Path(api_git.work_dir) + try: + api_git.clone("main", depth=1) + except GitOperationsError as e: + print(f"::error::Could not clone the API repository: {e}") + return CommonSyncOutcome(status="failed") + try: + commonalities_git.clone(release, depth=1) + except GitOperationsError as e: + print(f"::error::Could not fetch Commonalities {release}: {e}") + return CommonSyncOutcome(status="failed") + + try: + # Reset the RA-owned branch to main first, then copy onto it. + api_git.create_branch(branch, reset=True) + synced = copy_common_files(Path(commonalities_git.work_dir) / "artifacts" / "common", api_repo, release) + except (GitOperationsError, CommonSyncError) as e: + print(f"::error::{e}") + return CommonSyncOutcome(status="failed") + + unexpected = tuple(sorted( + p.name for p in (api_repo / COMMON_DIR).iterdir() + if p.name != MANIFEST_FILENAME and p.name not in synced + )) + if unexpected: + print( + f"::warning::code/common/ has files not provided by Commonalities {release}: " + f"{', '.join(unexpected)}" + ) + + _close_superseded_prs(gh, branch, release) + + try: + if not api_git.has_changes(COMMON_DIR): + print(f"code/common already matches Commonalities {release}; nothing to sync") + return CommonSyncOutcome(status="no_changes", unexpected_files=unexpected) + api_git.configure_user(bot_name, bot_email) + api_git.commit_all(f"chore: sync common files from Commonalities {release}") + # Force: the branch is rebuilt from main each run; RA is its only owner. + api_git.push(branch, set_upstream=False, force=True) + except GitOperationsError as e: + print(f"::error::Could not push {branch}: {e}") + return CommonSyncOutcome(status="failed", unexpected_files=unexpected) + + existing = gh.find_open_pr(branch) + if existing: + number, url = existing + print(f"Reusing open sync PR: {url}") + return CommonSyncOutcome(status="updated", pr_url=url, pr_number=str(number), unexpected_files=unexpected) + + try: + number, url = gh.create_pr( + title=f"Sync common files from Commonalities {release}", + body=pr_body(release, unexpected), + head=branch, + base="main", + ) + except Exception as e: + print(f"::error::Failed to create sync PR: {e}") + return CommonSyncOutcome(status="failed", unexpected_files=unexpected) + try: + gh.add_pr_labels(number, PR_LABELS) + except Exception as e: + print(f"Could not add labels: {e}") + print(f"Sync PR created: {url}") + return CommonSyncOutcome(status="created", pr_url=url, pr_number=str(number), unexpected_files=unexpected) + + +def main(client=None) -> int: from release_automation.scripts.trigger import write_outputs - check = detect(Path(os.environ["API_REPO_PATH"]), os.environ["COMMONALITIES_RELEASE"]) + release = os.environ["COMMONALITIES_RELEASE"] + check = detect(Path(os.environ["API_REPO_PATH"]), release, os.environ.get("ICM_RELEASE", "")) + sync_pr_url = "" + if check.status == "stale": + if client is None: + from .github_client import GitHubClient + client = GitHubClient(repo=os.environ["GITHUB_REPOSITORY"], token=os.environ.get("GITHUB_TOKEN")) + sync_pr_url = find_sync_pr(client, release) + print(f"Found existing sync PR: {sync_pr_url}" if sync_pr_url else "No existing sync PR found") write_outputs(os.environ["GITHUB_OUTPUT"], { "common_cache_status": check.status, "common_cache_details": check.details, + "common_sync_pr_url": sync_pr_url, }) return 0 diff --git a/release_automation/scripts/git_operations.py b/release_automation/scripts/git_operations.py index be7abb14..50f57062 100644 --- a/release_automation/scripts/git_operations.py +++ b/release_automation/scripts/git_operations.py @@ -57,7 +57,13 @@ class GitOperations: changes using local git commands and gh CLI for PR creation. """ - def __init__(self, repo: str, work_dir: str, token: Optional[str] = None): + def __init__( + self, + repo: str, + work_dir: str, + token: Optional[str] = None, + remote_url: Optional[str] = None, + ): """ Initialize git operations. @@ -65,11 +71,13 @@ def __init__(self, repo: str, work_dir: str, token: Optional[str] = None): repo: Repository in format "owner/name" (e.g., "camaraproject/QualityOnDemand") work_dir: Local directory path for git operations token: Optional GitHub token for authentication + remote_url: Clone URL instead of the github.com one (tests, local mirrors) """ self.repo = repo self.work_dir = work_dir self.token = token - self._repo_url = f"https://github.com/{repo}.git" + self._remote_url = remote_url + self._repo_url = remote_url or f"https://github.com/{repo}.git" def _run_git( self, @@ -145,12 +153,13 @@ def _run_gh(self, args: list, check: bool = True) -> str: except subprocess.CalledProcessError as e: raise GitOperationsError(f"gh {' '.join(args)} failed: {e.stderr}") - def clone(self, branch: str = "main") -> None: + def clone(self, branch: str = "main", depth: Optional[int] = None) -> None: """ Clone repository to work_dir. Args: - branch: Branch to checkout after cloning + branch: Branch or tag to checkout after cloning + depth: Shallow-clone depth (full history when None) Raises: CloneError: If cloning fails @@ -161,13 +170,14 @@ def clone(self, branch: str = "main") -> None: repo_name = os.path.basename(self.work_dir) # Build clone URL with token if available - if self.token: + if self.token and not self._remote_url: clone_url = f"https://x-access-token:{self.token}@github.com/{self.repo}.git" else: clone_url = self._repo_url + depth_args = ["--depth", str(depth)] if depth else [] subprocess.run( - ["git", "clone", "--branch", branch, clone_url, repo_name], + ["git", "clone", *depth_args, "--branch", branch, clone_url, repo_name], capture_output=True, text=True, check=True, @@ -191,19 +201,21 @@ def get_commit_sha(self, ref: str = "HEAD") -> str: """ return self._run_git(["rev-parse", ref]) - def create_branch(self, name: str, from_ref: str = "HEAD") -> None: + def create_branch(self, name: str, from_ref: str = "HEAD", reset: bool = False) -> None: """ Create and checkout a new branch. Args: name: Name of the new branch from_ref: Reference to branch from + reset: Reset the branch to from_ref if it already exists + (checkout -B; working-tree changes are kept) Raises: BranchError: If branch creation fails """ try: - self._run_git(["checkout", "-b", name, from_ref]) + self._run_git(["checkout", "-B" if reset else "-b", name, from_ref]) except GitOperationsError as e: raise BranchError(str(e)) @@ -257,19 +269,35 @@ def commit_all(self, message: str, author: Optional[str] = None) -> str: except GitOperationsError as e: raise CommitError(str(e)) - def push(self, branch: str, set_upstream: bool = True) -> None: + def has_changes(self, path: Optional[str] = None) -> bool: + """ + Check for uncommitted changes, including untracked files. + + Args: + path: Limit the check to this path + + Returns: + True if the working tree differs from HEAD + """ + args = ["status", "--porcelain"] + if path: + args.extend(["--", path]) + return bool(self._run_git(args)) + + def push(self, branch: str, set_upstream: bool = True, force: bool = False) -> None: """ Push branch to remote origin. Args: branch: Branch name to push set_upstream: Whether to set upstream tracking + force: Overwrite the remote branch (for branches owned by automation) Raises: PushError: If push fails """ try: - args = ["push"] + args = ["push", "--force"] if force else ["push"] if set_upstream: args.extend(["-u", "origin", branch]) else: diff --git a/release_automation/scripts/github_client.py b/release_automation/scripts/github_client.py index 9e1a66eb..d4f5816d 100644 --- a/release_automation/scripts/github_client.py +++ b/release_automation/scripts/github_client.py @@ -11,7 +11,7 @@ import subprocess import time from dataclasses import dataclass -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Tuple import yaml @@ -562,6 +562,94 @@ def find_pr_for_branch(self, head_branch: str) -> Optional[int]: except (GitHubClientError, ValueError): return None + def find_open_pr(self, head_branch: str) -> Optional[Tuple[int, str]]: + """ + Find the open PR whose head is exactly head_branch. + + Returns: + (PR number, PR URL), or None if there is none or the lookup fails + """ + try: + output = self._run_gh([ + "pr", "list", + "--repo", self.repo, + "--head", head_branch, + "--state", "open", + "--json", "number,url", + ]) + prs = json.loads(output or "[]") + except (GitHubClientError, ValueError) as e: + print(f"Warning: Could not look up PR for {head_branch}: {e}") + return None + return (prs[0]["number"], prs[0]["url"]) if prs else None + + def list_open_prs(self) -> List[Dict[str, Any]]: + """ + List open PRs. + + Returns: + Dicts with number, head (branch name) and url + """ + output = self._run_gh([ + "pr", "list", + "--repo", self.repo, + "--state", "open", + "--limit", "100", + "--json", "number,headRefName,url", + ]) + return [ + {"number": pr["number"], "head": pr["headRefName"], "url": pr["url"]} + for pr in json.loads(output or "[]") + ] + + def create_pr(self, title: str, body: str, head: str, base: str) -> Tuple[int, str]: + """ + Open a PR from a branch of this repository. + + Returns: + (PR number, PR URL) + + Raises: + GitHubClientError: If creation fails or the URL cannot be parsed + """ + output = self._run_gh([ + "pr", "create", + "--repo", self.repo, + "--title", title, + "--body", body, + "--head", head, + "--base", base, + ]) + url = output.strip() + try: + return int(url.rstrip("/").split("/")[-1]), url + except ValueError: + raise GitHubClientError(f"Failed to parse PR number from: {url}") + + def add_pr_labels(self, pr_number: int, labels: List[str]) -> None: + """ + Add existing labels to a PR (fails if a label does not exist). + + Raises: + GitHubClientError: If the edit fails + """ + args = ["pr", "edit", str(pr_number), "--repo", self.repo] + for label in labels: + args.extend(["--add-label", label]) + self._run_gh(args) + + def close_pr(self, pr_number: int, comment: str = "") -> None: + """ + Close a PR without merging, optionally with a comment. + + Raises: + GitHubClientError: If closing fails + """ + args = ["pr", "close", str(pr_number), "--repo", self.repo] + if comment: + args.extend(["--comment", comment]) + self._run_gh(args) + # ------------------------------------------------------------------------- # Retry helper for eventual consistency # ------------------------------------------------------------------------- diff --git a/release_automation/scripts/sync_pr.py b/release_automation/scripts/sync_pr.py new file mode 100644 index 00000000..f4d26bcc --- /dev/null +++ b/release_automation/scripts/sync_pr.py @@ -0,0 +1,182 @@ +""" +Post-release sync PR for CAMARA release automation. + +After a publication, main receives the README release-information block and +the release-cycle CHANGELOG from the release tag through a pr-to-main/ +PR. The outcome's status (created | failed | no_changes) is the boundary +between the published release and this follow-up: it decides whether the +Release Issue closes, and a failure here never fails the publication. + +The branch is rebuilt from main on every run and an open PR for it is reused, +so a re-run after a partial failure converges instead of failing on push. +""" + +import re +from dataclasses import dataclass +from pathlib import Path +from typing import List, Optional + +from .git_operations import GitOperationsError +from .github_client import GitHubClientError + +RELEASE_INFO_START = "" +RELEASE_INFO_END = "" +SYNC_BRANCH_PREFIX = "pr-to-main/" +PR_LABELS = ["post-release", "automated"] + + +@dataclass(frozen=True) +class SyncPrOutcome: + """Result of the post-release sync; pr_url/pr_number are set when created.""" + status: str + pr_url: str = "" + pr_number: str = "" + + +def _warning(message: str) -> None: + print(f"::warning::{message}") + + +def _release_info_block(readme: str) -> Optional[str]: + start = readme.find(RELEASE_INFO_START) + end = readme.find(RELEASE_INFO_END) + if start == -1 or end == -1 or end < start: + return None + return readme[start:end + len(RELEASE_INFO_END)] + + +def sync_readme(gh, work_dir: Path, tag: str) -> bool: + """Copy the release-information block of README.md from the tag; True if main's README changed.""" + tag_readme = gh.get_file_content("README.md", ref=tag) + if tag_readme is None: + _warning(f"Could not read README.md from tag {tag}") + return False + tag_block = _release_info_block(tag_readme) + if tag_block is None: + _warning("Tag README missing release info delimiters") + return False + + local_path = work_dir / "README.md" + if not local_path.is_file(): + _warning("README.md not found in API repo checkout") + return False + local = local_path.read_text(encoding="utf-8") + local_block = _release_info_block(local) + if local_block is None: + _warning("Main README missing release info delimiters") + return False + + start = local.find(RELEASE_INFO_START) + updated = local[:start] + tag_block + local[start + len(local_block):] + if updated == local: + print("README release info already up to date") + return False + local_path.write_text(updated, encoding="utf-8") + print("README release info synced from tag") + return True + + +def changelog_candidates(tag: str) -> List[str]: + """Per-cycle CHANGELOG first, then the flat CHANGELOG.md of repos not yet migrated.""" + match = re.match(r"^r(\d+)\.\d+", tag) + candidates = [f"CHANGELOG/CHANGELOG-r{match.group(1)}.md"] if match else [] + return candidates + ["CHANGELOG.md"] + + +def sync_changelog(gh, work_dir: Path, tag: str) -> str: + """Copy the release-cycle CHANGELOG from the tag; returns its path, or "" if none was found.""" + candidates = changelog_candidates(tag) + for path in candidates: + content = gh.get_file_content(path, ref=tag) + if content is None: + continue + local_path = work_dir / path + local_path.parent.mkdir(parents=True, exist_ok=True) + local_path.write_text(content, encoding="utf-8") + print(f"Synced {path} from tag {tag}") + return path + _warning(f"No CHANGELOG found at {' or '.join(candidates)} on tag {tag}") + return "" + + +def pr_body(tag: str) -> str: + return ( + f"Automated post-release sync PR for {tag}.\n" + "\n" + "This PR updates:\n" + "- README.md Release Information section (copied from release tag)\n" + "- CHANGELOG for the release cycle\n" + "\n" + "Created by release automation workflow." + ) + + +def _failed() -> SyncPrOutcome: + print("::error::Failed to create post-release sync PR") + return SyncPrOutcome(status="failed") + + +def create_sync_pr(gh, git, tag: str, bot_name: str, bot_email: str) -> SyncPrOutcome: + """ + Open the post-release sync PR for a published release. + + Args: + gh: GitHub client of the API repository + git: Git client whose work_dir receives a clone of main + tag: Published release tag, e.g. "r4.1" + bot_name: Commit author and committer name + bot_email: Commit author and committer email + + Returns: + SyncPrOutcome; never raises for git or GitHub errors + """ + branch = f"{SYNC_BRANCH_PREFIX}{tag}" + work_dir = Path(git.work_dir) + try: + git.clone("main", depth=1) + except GitOperationsError as e: + print(f"Clone failed: {e}") + return _failed() + + readme_changed = sync_readme(gh, work_dir, tag) + changelog_path = sync_changelog(gh, work_dir, tag) + if not readme_changed and not changelog_path: + _warning("No content changes to sync") + return SyncPrOutcome(status="no_changes") + + try: + git.create_branch(branch, reset=True) + if not git.has_changes(): + _warning("No actual file changes detected") + return SyncPrOutcome(status="no_changes") + + existing = gh.find_open_pr(branch) + if existing: + number, url = existing + print(f"Reusing open sync PR: {url}") + return SyncPrOutcome(status="created", pr_url=url, pr_number=str(number)) + + git.configure_user(bot_name, bot_email) + git.commit_all(f"chore: post-release sync for {tag}") + git.push(branch, set_upstream=False, force=True) + except GitOperationsError as e: + print(f"Git operation failed: {e}") + return _failed() + + try: + number, url = gh.create_pr( + title=f"Release Automation: Post-release sync ({tag})", + body=pr_body(tag), + head=branch, + base="main", + ) + except GitHubClientError as e: + print(f"PR creation failed: {e}") + return _failed() + + try: + gh.add_pr_labels(number, PR_LABELS) + except GitHubClientError as e: + print(f"Could not add labels: {e}") + print(f"Sync PR created: {url}") + return SyncPrOutcome(status="created", pr_url=url, pr_number=str(number)) diff --git a/release_automation/tests/conftest.py b/release_automation/tests/conftest.py new file mode 100644 index 00000000..86575475 --- /dev/null +++ b/release_automation/tests/conftest.py @@ -0,0 +1,10 @@ +import pytest + + +@pytest.fixture(autouse=True) +def isolated_git_config(tmp_path_factory, monkeypatch): + """Keep the developer's git config (signing, hooks, identity) out of git-level tests.""" + empty = tmp_path_factory.getbasetemp() / "gitconfig-empty" + empty.touch() + monkeypatch.setenv("GIT_CONFIG_GLOBAL", str(empty)) + monkeypatch.setenv("GIT_CONFIG_NOSYSTEM", "1") diff --git a/release_automation/tests/local_git.py b/release_automation/tests/local_git.py new file mode 100644 index 00000000..8f7acf60 --- /dev/null +++ b/release_automation/tests/local_git.py @@ -0,0 +1,55 @@ +"""Local bare repositories standing in for GitHub remotes in git-level tests.""" + +import subprocess +from pathlib import Path +from typing import Dict, Optional + +from release_automation.scripts.git_operations import GitOperations + + +def git(cwd: Path, *args: str) -> str: + return subprocess.run( + ["git", *args], cwd=cwd, check=True, capture_output=True, text=True + ).stdout.strip() + + +def make_remote(base: Path, name: str, files: Dict[str, str], tag: Optional[str] = None) -> Path: + """Bare repository with one commit on main holding files (and an optional tag).""" + seed = base / f"{name}-seed" + seed.mkdir(parents=True) + git(seed, "init", "-q", "-b", "main") + git(seed, "config", "user.name", "seed") + git(seed, "config", "user.email", "seed@example.com") + write_files(seed, files) + git(seed, "add", "-A") + git(seed, "commit", "-q", "-m", "seed") + if tag: + git(seed, "tag", tag) + bare = base / f"{name}.git" + git(base, "clone", "-q", "--bare", str(seed), str(bare)) + return bare + + +def write_files(root: Path, files: Dict[str, str]) -> None: + for rel, content in files.items(): + path = root / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + +def push_branch(bare: Path, base: Path, branch: str, files: Dict[str, str]) -> None: + """Add a branch on the remote with one extra commit on top of main.""" + work = base / f"push-{branch.replace('/', '-')}" + git(base, "clone", "-q", str(bare), str(work)) + git(work, "config", "user.name", "someone") + git(work, "config", "user.email", "someone@example.com") + git(work, "checkout", "-q", "-b", branch) + write_files(work, files) + git(work, "add", "-A") + git(work, "commit", "-q", "-m", "earlier run") + git(work, "push", "-q", "origin", branch) + + +def git_ops(bare: Path, work_dir: Path, repo: str = "camaraproject/Example") -> GitOperations: + """GitOperations bound to a local bare repository (file:// so --depth applies).""" + return GitOperations(repo=repo, work_dir=str(work_dir), remote_url=bare.as_uri()) diff --git a/release_automation/tests/test_common_sync.py b/release_automation/tests/test_common_sync.py index b66ce660..73296b07 100644 --- a/release_automation/tests/test_common_sync.py +++ b/release_automation/tests/test_common_sync.py @@ -1,16 +1,30 @@ -"""Tests for common_sync.py detection (code/common/ cache against the declared release). +"""Tests for common_sync.py (code/common/ cache against the declared release). -Expected values reproduce the former inline `Check common cache sync` step of -release-automation-reusable.yml. +Detection values reproduce the former inline `Check common cache sync` step of +release-automation-reusable.yml; sync values reproduce the common-sync steps +of its former update-issue job, plus the fix batch (H1, H2, M2, M3, L1) noted +on the tests. Git runs for real against local bare repositories. """ from pathlib import Path import pytest +import yaml + from release_automation.scripts import common_sync -from release_automation.scripts.common_sync import CacheCheck, describe, detect -from tooling_lib.cache_sync import SourceStatus, SyncStatus +from release_automation.scripts.common_sync import ( + CacheCheck, + CommonSyncOutcome, + describe, + detect, + find_sync_pr, + sync_common, +) +from release_automation.scripts.git_operations import GitOperations +from release_automation.scripts.github_client import GitHubClientError +from release_automation.tests.local_git import git, git_ops, make_remote, push_branch +from tooling_lib.cache_sync import SourceStatus, SyncStatus, git_blob_sha @pytest.mark.parametrize("release", ["", "main", "r4", "v4.2", "r4.2.1"]) @@ -83,10 +97,362 @@ def test_main_writes_outputs(tmp_path, monkeypatch): monkeypatch.setenv("API_REPO_PATH", str(tmp_path / "missing")) monkeypatch.setenv("COMMONALITIES_RELEASE", "r4.2") - common_sync.main() + common_sync.main(client=FakeGitHub()) from release_automation.tests.test_trigger import read_outputs assert read_outputs(output) == { "common_cache_status": "stale", "common_cache_details": "code/common/ directory is missing", + "common_sync_pr_url": "", } + + +# ── detection: ICM release (M3) ────────────────────────────────────────────── + +ICM = "IdentityAndConsentManagement" + + +def write_manifest(api_repo: Path, sources): + common = api_repo / "code" / "common" + common.mkdir(parents=True, exist_ok=True) + (common / ".sync-manifest.yaml").write_text(yaml.safe_dump({"sources": sources})) + + +def capture_expected(monkeypatch): + seen = {} + + def fake_check(path, expected): + seen["expected"] = expected + return SyncStatus(sources=[]) + + monkeypatch.setattr(common_sync, "check_sync_status", fake_check) + return seen + + +def test_icm_release_checked_when_manifest_records_icm(tmp_path, monkeypatch): + write_manifest(tmp_path, [ + {"repository": "Commonalities", "release": "r4.2", "files": {}}, + {"repository": ICM, "release": "r4.1", "files": {}}, + ]) + seen = capture_expected(monkeypatch) + + detect(tmp_path, "r4.3", icm_release="r4.2") + + assert seen["expected"] == {"Commonalities": "r4.3", ICM: "r4.2"} + + +@pytest.mark.parametrize("icm_release", ["", "r4.2"]) +def test_icm_release_ignored_without_icm_files(tmp_path, monkeypatch, icm_release): + # ICM publishes no artifacts/common today; requiring it would leave every cache stale. + write_manifest(tmp_path, [{"repository": "Commonalities", "release": "r4.2", "files": {}}]) + seen = capture_expected(monkeypatch) + + detect(tmp_path, "r4.3", icm_release=icm_release) + + assert seen["expected"] == {"Commonalities": "r4.3"} + + +def test_icm_release_ignored_with_unreadable_manifest(tmp_path, monkeypatch): + (tmp_path / "code" / "common").mkdir(parents=True) + (tmp_path / "code" / "common" / ".sync-manifest.yaml").write_text(": not yaml :\n - [") + seen = capture_expected(monkeypatch) + + detect(tmp_path, "r4.3", icm_release="r4.2") + + assert seen["expected"] == {"Commonalities": "r4.3"} + + +# ── existing sync PR lookup (L1) ───────────────────────────────────────────── + + +class FakeGitHub: + def __init__(self, open_prs=(), fail_create=False, fail_close=False): + self.prs = list(open_prs) + self.fail_create = fail_create + self.fail_close = fail_close + self.closed = [] + self.created = [] + self.labelled = [] + + def find_open_pr(self, head_branch): + for pr in self.prs: + if pr["head"] == head_branch: + return pr["number"], pr["url"] + return None + + def list_open_prs(self): + return list(self.prs) + + def close_pr(self, pr_number, comment=""): + if self.fail_close: + raise GitHubClientError("gh command failed: close") + self.closed.append((pr_number, comment)) + + def create_pr(self, title, body, head, base): + if self.fail_create: + raise GitHubClientError("gh command failed: create") + self.created.append({"title": title, "body": body, "head": head, "base": base}) + return 40, "https://github.com/camaraproject/Example/pull/40" + + def add_pr_labels(self, pr_number, labels): + self.labelled.append((pr_number, list(labels))) + + +def pr(number, head): + return {"number": number, "head": head, "url": f"https://github.com/camaraproject/Example/pull/{number}"} + + +def test_find_sync_pr_matches_the_exact_branch(): + gh = FakeGitHub([pr(5, "sync-common/r4.2"), pr(6, "sync-common/r4.3-old"), pr(7, "sync-common/r4.3")]) + + assert find_sync_pr(gh, "r4.3") == "https://github.com/camaraproject/Example/pull/7" + + +def test_find_sync_pr_ignores_other_sync_branches(): + gh = FakeGitHub([pr(5, "sync-common/r4.2")]) + + assert find_sync_pr(gh, "r4.3") == "" + + +def test_main_looks_up_sync_pr_when_stale(tmp_path, monkeypatch): + output = tmp_path / "output" + output.write_text("") + monkeypatch.setenv("GITHUB_OUTPUT", str(output)) + monkeypatch.setenv("API_REPO_PATH", str(tmp_path / "missing")) + monkeypatch.setenv("COMMONALITIES_RELEASE", "r4.3") + monkeypatch.setenv("ICM_RELEASE", "r4.2") + + common_sync.main(client=FakeGitHub([pr(9, "sync-common/r4.3")])) + + from release_automation.tests.test_trigger import read_outputs + assert read_outputs(output)["common_sync_pr_url"] == "https://github.com/camaraproject/Example/pull/9" + + +# ── common sync ────────────────────────────────────────────────────────────── + +OLD_COMMON = "openapi: 3.0.3\ninfo: {title: old}\n" +NEW_COMMON = "openapi: 3.0.3\ninfo: {title: new}\n" +NEW_EVENT = "openapi: 3.0.3\ninfo: {title: event}\n" + +SYNC_PR_BODY = """> [!WARNING] +> This branch is owned by Release Automation and is rebuilt from `main` on every run. +> **Do not commit fixes to this branch** — any commits here are overwritten the next time the sync runs. + +Automated sync of `code/common/` files from `camaraproject/Commonalities` at release tag `r4.3`. + +This PR was created by release automation because the cached Commonalities files were out of sync with the declared dependency in `release-plan.yaml`. + +**What changed:** +- Updated files in `code/common/` to match Commonalities r4.3 +- Added/updated `.sync-manifest.yaml` with file integrity hashes + +**How to handle this PR:** +- **Validation green** — review and merge. +- **Validation red** — choose one: + - *(recommended)* fix the errors on `main`, then re-trigger the sync; this PR is rebuilt from the new `main` HEAD with the corrected content. + - merge this PR with the errors and fix them on `main` afterwards. + +To re-trigger the sync, run the **CAMARA Release Automation** workflow from the repository's **Actions** tab. + +`/create-snapshot` is blocked until common files are in sync.""" + + +@pytest.fixture +def remotes(tmp_path): + api = make_remote(tmp_path, "api", { + "README.md": "# Example\n", + "code/common/CAMARA_common.yaml": OLD_COMMON, + }) + commonalities = make_remote(tmp_path, "commonalities", { + "artifacts/common/CAMARA_common.yaml": NEW_COMMON, + "artifacts/common/CAMARA_event_common.yaml": NEW_EVENT, + "artifacts/common/README.md": "not synced\n", + }, tag="r4.3") + return api, commonalities + + +def run_sync_common(tmp_path, remotes, gh, release="r4.3"): + api, commonalities = remotes + return sync_common( + git_ops(api, tmp_path / "work-api"), + git_ops(commonalities, tmp_path / "work-commonalities", repo="camaraproject/Commonalities"), + gh, + release, + "release-bot[bot]", + "7+release-bot[bot]@users.noreply.github.com", + ) + + +def test_sync_copies_files_writes_manifest_and_opens_pr(tmp_path, remotes): + api, _ = remotes + gh = FakeGitHub() + + outcome = run_sync_common(tmp_path, remotes, gh) + + assert outcome == CommonSyncOutcome( + status="created", pr_url="https://github.com/camaraproject/Example/pull/40", pr_number="40" + ) + branch = "sync-common/r4.3" + assert git(api, "show", f"{branch}:code/common/CAMARA_common.yaml") + "\n" == NEW_COMMON + assert git(api, "show", f"{branch}:code/common/CAMARA_event_common.yaml") + "\n" == NEW_EVENT + manifest = yaml.safe_load(git(api, "show", f"{branch}:code/common/.sync-manifest.yaml")) + assert manifest == {"sources": [{ + "repository": "Commonalities", + "release": "r4.3", + "files": { + "CAMARA_common.yaml": git_blob_sha(NEW_COMMON.encode()), + "CAMARA_event_common.yaml": git_blob_sha(NEW_EVENT.encode()), + }, + }]} + assert git(api, "log", "-1", "--format=%s|%an", branch) == ( + "chore: sync common files from Commonalities r4.3|release-bot[bot]" + ) + assert git(api, "rev-parse", f"{branch}~1") == git(api, "rev-parse", "main") + assert gh.created == [{ + "title": "Sync common files from Commonalities r4.3", + "body": SYNC_PR_BODY, + "head": branch, + "base": "main", + }] + assert gh.labelled == [(40, ["common-sync", "automated"])] + + +def test_synced_cache_is_in_sync(tmp_path, remotes): + run_sync_common(tmp_path, remotes, FakeGitHub()) + + assert detect(tmp_path / "work-api", "r4.3") == CacheCheck(status="in_sync") + + +def test_sync_branch_from_an_earlier_run_is_rebuilt_from_main(tmp_path, remotes): + # H1: the sync branch is reset to main before the files are copied, so a + # stale remote branch (or a hand commit on it) never blocks the run. + api, _ = remotes + push_branch(api, tmp_path, "sync-common/r4.3", {"code/common/CAMARA_common.yaml": "hand edit\n"}) + + assert run_sync_common(tmp_path, remotes, FakeGitHub()).status == "created" + assert git(api, "show", "sync-common/r4.3:code/common/CAMARA_common.yaml") + "\n" == NEW_COMMON + assert git(api, "rev-parse", "sync-common/r4.3~1") == git(api, "rev-parse", "main") + + +def test_branch_is_reset_before_files_are_copied(tmp_path, remotes, monkeypatch): + calls = [] + real_copy = common_sync.copy_common_files + real_branch = GitOperations.create_branch + monkeypatch.setattr(common_sync, "copy_common_files", lambda *a: calls.append("copy") or real_copy(*a)) + monkeypatch.setattr( + GitOperations, "create_branch", + lambda self, *a, **k: calls.append("branch") or real_branch(self, *a, **k), + ) + + run_sync_common(tmp_path, remotes, FakeGitHub()) + + assert calls == ["branch", "copy"] + + +def test_open_pr_for_the_branch_is_reused(tmp_path, remotes): + gh = FakeGitHub([pr(33, "sync-common/r4.3")]) + + assert run_sync_common(tmp_path, remotes, gh) == CommonSyncOutcome( + status="updated", pr_url="https://github.com/camaraproject/Example/pull/33", pr_number="33" + ) + assert gh.created == [] + + +def test_sync_prs_for_other_releases_are_closed(tmp_path, remotes): + gh = FakeGitHub([pr(31, "sync-common/r4.2"), pr(32, "feature/x"), pr(33, "sync-common/r4.3")]) + + run_sync_common(tmp_path, remotes, gh) + + assert gh.closed == [(31, "Superseded by sync to Commonalities r4.3")] + + +def test_close_failure_does_not_stop_the_sync(tmp_path, remotes): + gh = FakeGitHub([pr(31, "sync-common/r4.2")], fail_close=True) + + assert run_sync_common(tmp_path, remotes, gh).status == "created" + + +def test_cache_already_matching_is_no_changes(tmp_path, remotes): + api, commonalities = remotes + run_sync_common(tmp_path, remotes, FakeGitHub()) + git(api, "update-ref", "refs/heads/main", "refs/heads/sync-common/r4.3") + + outcome = sync_common( + git_ops(api, tmp_path / "again-api"), + git_ops(commonalities, tmp_path / "again-commonalities"), + FakeGitHub(), "r4.3", "bot", "bot@example.com", + ) + + assert outcome == CommonSyncOutcome(status="no_changes") + + +def test_unexpected_files_are_flagged_not_deleted(tmp_path, capsys): + # M2: files under code/common/ that the Commonalities release does not provide + api = make_remote(tmp_path, "api", { + "code/common/CAMARA_common.yaml": OLD_COMMON, + "code/common/local_extra.yaml": "x: 1\n", + "code/common/notes.md": "notes\n", + }) + commonalities = make_remote(tmp_path, "commonalities", { + "artifacts/common/CAMARA_common.yaml": NEW_COMMON, + }, tag="r4.3") + gh = FakeGitHub() + + outcome = run_sync_common(tmp_path, (api, commonalities), gh) + + assert outcome.unexpected_files == ("local_extra.yaml", "notes.md") + assert git(api, "show", "sync-common/r4.3:code/common/local_extra.yaml") == "x: 1" + assert gh.created[0]["body"].endswith( + "\n\n**Not provided by Commonalities r4.3:** `local_extra.yaml`, `notes.md` in " + "`code/common/` — remove them on `main` if they are no longer needed." + ) + assert ( + "::warning::code/common/ has files not provided by Commonalities r4.3: local_extra.yaml, notes.md" + in capsys.readouterr().out + ) + + +# H2: every failed common sync step is a failed outcome (the caller posts common_sync_failed). + + +def test_unknown_commonalities_release_is_failed(tmp_path, remotes, capsys): + gh = FakeGitHub() + + assert run_sync_common(tmp_path, remotes, gh, release="r9.9") == CommonSyncOutcome(status="failed") + assert "::error::Could not fetch Commonalities r9.9" in capsys.readouterr().out + assert gh.created == [] + + +def test_api_clone_failure_is_failed(tmp_path, remotes): + _, commonalities = remotes + + assert run_sync_common(tmp_path, (tmp_path / "missing.git", commonalities), FakeGitHub()).status == "failed" + + +@pytest.mark.parametrize( + "files,message", + [ + ({"README.md": "x\n"}, "Commonalities artifacts/common not found at tag r4.3"), + ({"artifacts/common/README.md": "x\n"}, "No YAML files in Commonalities artifacts/common at r4.3"), + ], +) +def test_release_without_common_files_is_failed(tmp_path, remotes, files, message, capsys): + api, _ = remotes + commonalities = make_remote(tmp_path, "bare-commonalities", files, tag="r4.3") + + assert run_sync_common(tmp_path, (api, commonalities), FakeGitHub()).status == "failed" + assert f"::error::{message}" in capsys.readouterr().out + + +def test_push_failure_is_failed(tmp_path, remotes): + api, _ = remotes + hook = api / "hooks" / "pre-receive" + hook.write_text("#!/bin/sh\nexit 1\n") + hook.chmod(0o755) + + assert run_sync_common(tmp_path, remotes, FakeGitHub()).status == "failed" + + +def test_pr_creation_failure_is_failed(tmp_path, remotes, capsys): + assert run_sync_common(tmp_path, remotes, FakeGitHub(fail_create=True)) == CommonSyncOutcome(status="failed") + assert "::error::Failed to create sync PR" in capsys.readouterr().out diff --git a/release_automation/tests/test_git_operations.py b/release_automation/tests/test_git_operations.py index 26443e05..b14f108d 100644 --- a/release_automation/tests/test_git_operations.py +++ b/release_automation/tests/test_git_operations.py @@ -392,3 +392,56 @@ def test_configure_user(self, mock_run, git_ops): email_call = calls[1][0][0] assert "user.email" in email_call assert "bot@example.com" in email_call + + +class TestLocalRemote: + """Clone, reset branch, change detection and force push against a local bare repository.""" + + def test_clone_shallow_from_remote_url(self, tmp_path): + from release_automation.tests.local_git import git, git_ops, make_remote + remote = make_remote(tmp_path, "r", {"a.txt": "a\n"}, tag="r1.1") + + ops = git_ops(remote, tmp_path / "work") + ops.clone("r1.1", depth=1) + + assert (tmp_path / "work" / "a.txt").read_text() == "a\n" + assert git(tmp_path / "work", "rev-parse", "--is-shallow-repository") == "true" + + def test_reset_branch_keeps_working_tree_changes(self, tmp_path): + from release_automation.tests.local_git import git_ops, make_remote + remote = make_remote(tmp_path, "r", {"a.txt": "a\n"}) + ops = git_ops(remote, tmp_path / "work") + ops.clone("main") + ops.create_branch("sync/x") + ops.checkout("main") + (tmp_path / "work" / "a.txt").write_text("changed\n") + + ops.create_branch("sync/x", reset=True) + + assert ops.has_changes() + assert ops.has_changes("a.txt") + assert not ops.has_changes("b") + + def test_has_changes_on_clean_tree(self, tmp_path): + from release_automation.tests.local_git import git_ops, make_remote + ops = git_ops(make_remote(tmp_path, "r", {"a.txt": "a\n"}), tmp_path / "work") + ops.clone("main") + + assert not ops.has_changes() + + def test_force_push_replaces_remote_branch(self, tmp_path): + from release_automation.tests.local_git import git, git_ops, make_remote, push_branch + remote = make_remote(tmp_path, "r", {"a.txt": "a\n"}) + push_branch(remote, tmp_path, "sync/x", {"a.txt": "old\n"}) + ops = git_ops(remote, tmp_path / "work") + ops.clone("main") + ops.configure_user("bot", "bot@example.com") + ops.create_branch("sync/x", reset=True) + (tmp_path / "work" / "a.txt").write_text("new\n") + ops.commit_all("sync") + + with pytest.raises(PushError): + ops.push("sync/x", set_upstream=False) + ops.push("sync/x", set_upstream=False, force=True) + + assert git(remote, "show", "sync/x:a.txt") == "new" diff --git a/release_automation/tests/test_github_client.py b/release_automation/tests/test_github_client.py index 819c31b2..fe2ca4b4 100644 --- a/release_automation/tests/test_github_client.py +++ b/release_automation/tests/test_github_client.py @@ -228,6 +228,58 @@ def test_create_branch_at_sha_api_error(self, mock_run_gh): with self.assertRaises(GitHubClientError): self.client.create_branch_at_sha("release/r4.1", "abc123") + @patch("release_automation.scripts.github_client.GitHubClient._run_gh") + def test_find_open_pr(self, mock_run_gh): + mock_run_gh.return_value = json.dumps([{"number": 7, "url": "https://github.com/owner/repo/pull/7"}]) + self.assertEqual(self.client.find_open_pr("sync-common/r4.3"), (7, "https://github.com/owner/repo/pull/7")) + args = mock_run_gh.call_args[0][0] + self.assertEqual(args[:2], ["pr", "list"]) + self.assertIn("--head", args) + self.assertEqual(args[args.index("--head") + 1], "sync-common/r4.3") + self.assertEqual(args[args.index("--state") + 1], "open") + + @patch("release_automation.scripts.github_client.GitHubClient._run_gh") + def test_find_open_pr_none(self, mock_run_gh): + mock_run_gh.return_value = "[]" + self.assertIsNone(self.client.find_open_pr("sync-common/r4.3")) + + @patch("release_automation.scripts.github_client.GitHubClient._run_gh") + def test_find_open_pr_api_error(self, mock_run_gh): + mock_run_gh.side_effect = GitHubClientError("500") + self.assertIsNone(self.client.find_open_pr("sync-common/r4.3")) + + @patch("release_automation.scripts.github_client.GitHubClient._run_gh") + def test_list_open_prs(self, mock_run_gh): + mock_run_gh.return_value = json.dumps([ + {"number": 3, "headRefName": "sync-common/r4.2", "url": "u3"}, + ]) + self.assertEqual(self.client.list_open_prs(), [{"number": 3, "head": "sync-common/r4.2", "url": "u3"}]) + + @patch("release_automation.scripts.github_client.GitHubClient._run_gh") + def test_create_pr(self, mock_run_gh): + mock_run_gh.return_value = "https://github.com/owner/repo/pull/12\n" + result = self.client.create_pr("T", "B", head="pr-to-main/r1.3", base="main") + self.assertEqual(result, (12, "https://github.com/owner/repo/pull/12")) + self.assertEqual(mock_run_gh.call_args[0][0], [ + "pr", "create", "--repo", "owner/repo", "--title", "T", "--body", "B", + "--head", "pr-to-main/r1.3", "--base", "main", + ]) + + @patch("release_automation.scripts.github_client.GitHubClient._run_gh") + def test_add_pr_labels(self, mock_run_gh): + self.client.add_pr_labels(12, ["post-release", "automated"]) + self.assertEqual(mock_run_gh.call_args[0][0], [ + "pr", "edit", "12", "--repo", "owner/repo", + "--add-label", "post-release", "--add-label", "automated", + ]) + + @patch("release_automation.scripts.github_client.GitHubClient._run_gh") + def test_close_pr_with_comment(self, mock_run_gh): + self.client.close_pr(3, comment="Superseded") + self.assertEqual(mock_run_gh.call_args[0][0], [ + "pr", "close", "3", "--repo", "owner/repo", "--comment", "Superseded", + ]) + @patch("release_automation.scripts.github_client.GitHubClient._run_gh") def test_get_collaborator_permission(self, mock_run_gh): mock_run_gh.return_value = "maintain\n" diff --git a/release_automation/tests/test_sync_pr.py b/release_automation/tests/test_sync_pr.py new file mode 100644 index 00000000..0c77d8b9 --- /dev/null +++ b/release_automation/tests/test_sync_pr.py @@ -0,0 +1,230 @@ +"""Tests for sync_pr.py (post-release README/CHANGELOG sync PR). + +Expected values reproduce the former create-sync-pr job of +release-automation-reusable.yml (two github-script blocks and the +"Create sync PR" shell step). Git runs for real against a local bare +repository; the GitHub side is a fake. +""" + +from pathlib import Path + +import pytest + +from release_automation.scripts.github_client import GitHubClientError +from release_automation.scripts.sync_pr import SyncPrOutcome, create_sync_pr +from release_automation.tests.local_git import git, git_ops, make_remote, push_branch + +START = "" +END = "" + +MAIN_README = f"# Example\n\nIntro.\n\n{START}\nNo release yet.\n{END}\n\nFooter.\n" +TAG_README = f"# Example\n\nIntro at tag.\n\n{START}\nRelease r1.3 is out.\n{END}\n" +SYNCED_README = f"# Example\n\nIntro.\n\n{START}\nRelease r1.3 is out.\n{END}\n\nFooter.\n" + +PR_BODY = ( + "Automated post-release sync PR for r1.3.\n" + "\n" + "This PR updates:\n" + "- README.md Release Information section (copied from release tag)\n" + "- CHANGELOG for the release cycle\n" + "\n" + "Created by release automation workflow." +) + + +class FakeGitHub: + def __init__(self, files=None, open_prs=None, fail_create=False, fail_labels=False): + self.files = files or {} + self.open_prs = open_prs or {} + self.fail_create = fail_create + self.fail_labels = fail_labels + self.created = [] + self.labelled = [] + + def get_file_content(self, path, ref="main", strict=False): + return self.files.get((ref, path)) + + def find_open_pr(self, head_branch): + return self.open_prs.get(head_branch) + + def create_pr(self, title, body, head, base): + if self.fail_create: + raise GitHubClientError("gh command failed: pull request create failed") + self.created.append({"title": title, "body": body, "head": head, "base": base}) + return 31, "https://github.com/camaraproject/Example/pull/31" + + def add_pr_labels(self, pr_number, labels): + if self.fail_labels: + raise GitHubClientError("gh command failed: label not found") + self.labelled.append((pr_number, list(labels))) + + +@pytest.fixture +def remote(tmp_path): + return make_remote(tmp_path, "api", { + "README.md": MAIN_README, + "CHANGELOG/CHANGELOG-r1.md": "# r1.2\n", + }) + + +def run(tmp_path, remote, gh, tag="r1.3"): + return create_sync_pr(gh, git_ops(remote, tmp_path / "api"), tag, "release-bot[bot]", "7+release-bot[bot]@users.noreply.github.com") + + +def remote_file(remote, branch, path): + return git(remote, "show", f"{branch}:{path}") + + +def tag_files(tag="r1.3", **files): + return {(tag, path): content for path, content in files.items()} + + +def test_readme_block_and_cycle_changelog_from_tag(tmp_path, remote): + gh = FakeGitHub(files={ + ("r1.3", "README.md"): TAG_README, + ("r1.3", "CHANGELOG/CHANGELOG-r1.md"): "# r1.3\n\n# r1.2\n", + }) + + outcome = run(tmp_path, remote, gh) + + assert outcome == SyncPrOutcome( + status="created", pr_url="https://github.com/camaraproject/Example/pull/31", pr_number="31" + ) + assert remote_file(remote, "pr-to-main/r1.3", "README.md") == SYNCED_README.rstrip("\n") + assert remote_file(remote, "pr-to-main/r1.3", "CHANGELOG/CHANGELOG-r1.md") == "# r1.3\n\n# r1.2" + assert git(remote, "log", "-1", "--format=%s|%an|%ae|%cn", "pr-to-main/r1.3") == ( + "chore: post-release sync for r1.3|release-bot[bot]|" + "7+release-bot[bot]@users.noreply.github.com|release-bot[bot]" + ) + assert git(remote, "rev-parse", "pr-to-main/r1.3~1") == git(remote, "rev-parse", "main") + assert gh.created == [{ + "title": "Release Automation: Post-release sync (r1.3)", + "body": PR_BODY, + "head": "pr-to-main/r1.3", + "base": "main", + }] + assert gh.labelled == [(31, ["post-release", "automated"])] + + +def test_flat_changelog_when_tag_has_no_cycle_file(tmp_path, remote): + gh = FakeGitHub(files=tag_files(**{"CHANGELOG.md": "# Changelog\n"})) + + assert run(tmp_path, remote, gh).status == "created" + assert remote_file(remote, "pr-to-main/r1.3", "CHANGELOG.md") == "# Changelog" + + +def test_cycle_changelog_is_preferred_over_flat(tmp_path, remote): + gh = FakeGitHub(files=tag_files(**{ + "CHANGELOG/CHANGELOG-r1.md": "# cycle\n", + "CHANGELOG.md": "# flat\n", + })) + + run(tmp_path, remote, gh) + + assert remote_file(remote, "pr-to-main/r1.3", "CHANGELOG/CHANGELOG-r1.md") == "# cycle" + assert "CHANGELOG.md" not in git(remote, "ls-tree", "-r", "--name-only", "pr-to-main/r1.3").split("\n") + + +def test_cycle_changelog_directory_is_created(tmp_path): + remote = make_remote(tmp_path, "api", {"README.md": MAIN_README}) + gh = FakeGitHub(files=tag_files("r2.1", **{"CHANGELOG/CHANGELOG-r2.md": "# r2.1\n"})) + + assert run(tmp_path, remote, gh, tag="r2.1").status == "created" + assert remote_file(remote, "pr-to-main/r2.1", "CHANGELOG/CHANGELOG-r2.md") == "# r2.1" + + +@pytest.mark.parametrize( + "files", + [ + {}, + {"README.md": "# Example\n\nNo delimiters at the tag.\n"}, + {"README.md": f"# Example\n\n{START}\nonly a start marker\n"}, + ], +) +def test_nothing_to_copy_is_no_changes(tmp_path, remote, files, capsys): + gh = FakeGitHub(files=tag_files(**files)) + + assert run(tmp_path, remote, gh) == SyncPrOutcome(status="no_changes") + assert "::warning::No content changes to sync" in capsys.readouterr().out + assert "pr-to-main/r1.3" not in git(remote, "branch", "--list", "pr-to-main/*") + assert gh.created == [] + + +def test_main_readme_without_delimiters_is_left_alone(tmp_path, capsys): + remote = make_remote(tmp_path, "api", {"README.md": "# Example\n"}) + gh = FakeGitHub(files=tag_files(**{"README.md": TAG_README, "CHANGELOG.md": "# c\n"})) + + assert run(tmp_path, remote, gh).status == "created" + assert "::warning::Main README missing release info delimiters" in capsys.readouterr().out + assert remote_file(remote, "pr-to-main/r1.3", "README.md") == "# Example" + + +def test_identical_content_is_no_changes(tmp_path, remote, capsys): + gh = FakeGitHub(files=tag_files(**{ + "README.md": MAIN_README, + "CHANGELOG/CHANGELOG-r1.md": "# r1.2\n", + })) + + assert run(tmp_path, remote, gh) == SyncPrOutcome(status="no_changes") + assert "::warning::No actual file changes detected" in capsys.readouterr().out + assert gh.created == [] + + +def test_pr_creation_failure_is_failed(tmp_path, remote, capsys): + gh = FakeGitHub(files=tag_files(**{"README.md": TAG_README}), fail_create=True) + + assert run(tmp_path, remote, gh) == SyncPrOutcome(status="failed") + assert "::error::Failed to create post-release sync PR" in capsys.readouterr().out + + +def test_label_failure_is_ignored(tmp_path, remote): + gh = FakeGitHub(files=tag_files(**{"README.md": TAG_README}), fail_labels=True) + + assert run(tmp_path, remote, gh).status == "created" + + +def test_clone_failure_is_failed(tmp_path, capsys): + gh = FakeGitHub(files=tag_files(**{"README.md": TAG_README})) + + outcome = run(tmp_path, tmp_path / "missing.git", gh) + + assert outcome == SyncPrOutcome(status="failed") + assert "::error::Failed to create post-release sync PR" in capsys.readouterr().out + + +def test_push_failure_is_failed(tmp_path, remote, capsys): + # A pre-receive hook rejecting every push stands in for a protected or unreachable remote. + hook = remote / "hooks" / "pre-receive" + hook.write_text("#!/bin/sh\nexit 1\n") + hook.chmod(0o755) + gh = FakeGitHub(files=tag_files(**{"README.md": TAG_README})) + + assert run(tmp_path, remote, gh) == SyncPrOutcome(status="failed") + assert gh.created == [] + assert "::error::Failed to create post-release sync PR" in capsys.readouterr().out + + +# ── re-runs: the pr-to-main/ branch may already exist ─────────────────── + + +def test_existing_branch_from_an_earlier_run_is_rebuilt(tmp_path, remote): + push_branch(remote, tmp_path, "pr-to-main/r1.3", {"README.md": "half-done\n"}) + gh = FakeGitHub(files=tag_files(**{"README.md": TAG_README})) + + assert run(tmp_path, remote, gh).status == "created" + assert remote_file(remote, "pr-to-main/r1.3", "README.md") == SYNCED_README.rstrip("\n") + assert git(remote, "rev-parse", "pr-to-main/r1.3~1") == git(remote, "rev-parse", "main") + + +def test_existing_open_pr_is_reused(tmp_path, remote): + push_branch(remote, tmp_path, "pr-to-main/r1.3", {"README.md": "kept\n"}) + gh = FakeGitHub( + files=tag_files(**{"README.md": TAG_README}), + open_prs={"pr-to-main/r1.3": (29, "https://github.com/camaraproject/Example/pull/29")}, + ) + + assert run(tmp_path, remote, gh) == SyncPrOutcome( + status="created", pr_url="https://github.com/camaraproject/Example/pull/29", pr_number="29" + ) + assert remote_file(remote, "pr-to-main/r1.3", "README.md") == "kept" + assert gh.created == [] diff --git a/validation/tests/test_release_automation_workflow.py b/validation/tests/test_release_automation_workflow.py index 78e1610c..5c35a837 100644 --- a/validation/tests/test_release_automation_workflow.py +++ b/validation/tests/test_release_automation_workflow.py @@ -129,7 +129,6 @@ def test_plan_runs_policy_then_one_response(): "Detect Trigger Type", "Derive Release State", "Check common cache sync", - "Find existing sync PR", "Apply Command Policy", "Assemble Context", "Post Response", @@ -141,6 +140,9 @@ def test_plan_runs_policy_then_one_response(): assert policy["env"]["TRIGGER_JSON"] == "${{ steps.detect.outputs.trigger }}" cache = _step_by_name(plan["steps"], "Check common cache sync") assert cache["run"].strip() == "python -m release_automation.scripts.common_sync" + # The existing-PR lookup is part of common_sync (exact sync-common/ head). + assert policy["env"]["COMMON_SYNC_PR_URL"] == "${{ steps.cache-sync.outputs.common_sync_pr_url }}" + assert cache["env"]["ICM_RELEASE"] == "${{ steps.state.outputs.identity_consent_management_release }}" respond = _step_by_name(plan["steps"], "Post Response") assert respond["uses"] == "./_tooling/shared-actions/post-bot-comment"