diff --git a/.github/workflows/auto-approve-codeflow.yml b/.github/workflows/auto-approve-codeflow.yml new file mode 100644 index 00000000000..d1a6367e69f --- /dev/null +++ b/.github/workflows/auto-approve-codeflow.yml @@ -0,0 +1,175 @@ +name: Auto-approve codeflow PRs + +on: + pull_request: + types: [opened, synchronize, reopened] + +permissions: + pull-requests: write + +jobs: + auto-approve-codeflow: + runs-on: ubuntu-latest + if: >- + github.event.pull_request.user.login == 'dotnet-maestro[bot]' + && github.actor == 'dotnet-maestro[bot]' + steps: + - name: Validate and auto-approve codeflow PR + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + GITHUB_REPOSITORY: ${{ github.repository }} + run: | + python3 - <<'PYTHON' + import os, re, subprocess, sys + + pr_number = os.environ["PR_NUMBER"] + repo = os.environ["GITHUB_REPOSITORY"] + + def gh(*args: str) -> str: + result = subprocess.run( + ["gh", *args, "--repo", repo], + capture_output=True, text=True, check=True) + return result.stdout + + # ---- regex building blocks ---------------------------------------- + VERSION = r'[0-9]+\.[0-9]+[A-Za-z0-9.\-+]*' + SHA = r'[0-9a-fA-F]{7,40}' + BARID = r'[0-9]+' + DOTNET_URL = r'https://github\.com/dotnet/[A-Za-z0-9._-]+' + FEED_URL = (r'https://pkgs\.dev\.azure\.com/dnceng/public/_packaging/' + r'[A-Za-z0-9._-]+/nuget/v3/index\.json') + + # Reject diff operations that should never appear in a version bump + REJECT_DIFF_META = re.compile( + r'^(rename (from|to) |copy (from|to) |new file mode |' + r'deleted file mode |old mode |new mode |' + r'GIT binary patch|similarity index |dissimilarity index )') + + VERSION_RE = re.compile(rf'({VERSION})') + + # ---- version comparison ------------------------------------------- + def parse_version(v: str) -> tuple: + base_str, _, pre_str = v.partition('-') + base = tuple(int(x) for x in base_str.split('.')) + if not pre_str: + return (base, (1,)) # release sorts above pre-release + pre: list = [] + for seg in pre_str.split('.'): + pre.append((0, int(seg)) if seg.isdigit() else (1, seg)) + return (base, (0, tuple(pre))) + + # ---- validate the diff -------------------------------------------- + print(f"Validating codeflow PR #{pr_number}...") + + diff_text = gh("pr", "diff", pr_number) + current_file: str | None = None + files_seen: set[str] = set() + errors: list[str] = [] + removed_versions: dict[tuple[str, str], str] = {} + added_versions: dict[tuple[str, str], str] = {} + + for raw_line in diff_text.splitlines(): + if raw_line.startswith("diff --git"): + parts = raw_line.split(" b/") + current_file = parts[-1] if len(parts) >= 2 else None + if current_file: + match current_file: + case ("eng/Version.Details.xml" + | "eng/Version.Details.props" + | "eng/Versions.props" + | "global.json" + | "NuGet.config"): + files_seen.add(current_file) + case _: + errors.append(f"Unexpected file: {current_file}") + current_file = None + continue + + if REJECT_DIFF_META.match(raw_line): + errors.append(f"Unexpected diff operation: {raw_line.strip()}") + continue + + if raw_line.startswith(("---", "+++", "@@", "\\ No newline")): + continue + + if not raw_line.startswith(("+", "-")): + continue + + sign = raw_line[0] + content = raw_line[1:] + if not content.strip(): + continue + + if current_file is None: + continue + + valid = False + ver_key: str | None = None + + match current_file: + case "eng/Version.Details.xml": + if re.match(rf'^\s*\s*$', content): + valid = True + elif re.match(rf'^\s*{SHA}\s*$', content): + valid = True + elif m := re.match(rf'^\s*\s*$', content): + valid, ver_key = True, m.group(1) + elif re.match(rf'^\s*{DOTNET_URL}\s*$', content): + valid = True + case "eng/Version.Details.props" | "eng/Versions.props": + if m := re.match(rf'^\s*<([A-Za-z][A-Za-z0-9]*)>{VERSION}\s*$', content): + valid, ver_key = True, m.group(1) + elif re.match(r'^\s*\s*$', content): + valid = True + case "global.json": + if m := re.match(rf'^\s*"([^"]+)"\s*:\s*"{VERSION}"\s*,?\s*$', content): + valid, ver_key = True, m.group(1) + case "NuGet.config": + if re.match(rf'^\s*\s*$', content): + valid = True + + if not valid: + errors.append(f"Unexpected change in {current_file}: {content.strip()}") + else: + if ver_key: + ver_match = VERSION_RE.search(content) + if ver_match: + pair = (current_file, ver_key) + if sign == '-': + removed_versions.setdefault(pair, ver_match.group(1)) + else: + added_versions.setdefault(pair, ver_match.group(1)) + + if not files_seen: + errors.append("No files found in the diff") + + for key in removed_versions: + if key in added_versions: + old_v, new_v = removed_versions[key], added_versions[key] + try: + old_parsed = parse_version(old_v) + new_parsed = parse_version(new_v) + except (ValueError, TypeError): + errors.append( + f"Unparseable version in {key[0]}: " + f"{key[1]} {old_v} / {new_v}") + continue + if new_parsed < old_parsed: + errors.append( + f"Version downgrade in {key[0]}: " + f"{key[1]} {old_v} -> {new_v}") + + if errors: + for e in errors: + print(f"::notice::{e}") + print("::notice::Skipping auto-approve – PR contains unexpected changes") + sys.exit(0) + + print("✔ All changes are expected dependency-update patterns") + + # ---- approve the PR ------------------------------------------------ + gh("pr", "review", pr_number, "--approve", + "--body", "Auto-approved: codeflow dependency update PR with only version/SHA bumps in expected files.") + print(f"✔ PR #{pr_number} approved") + PYTHON