|
| 1 | +name: Auto-approve codeflow PRs |
| 2 | + |
| 3 | +on: |
| 4 | + pull_request: |
| 5 | + types: [opened, synchronize, reopened] |
| 6 | + |
| 7 | +permissions: |
| 8 | + pull-requests: write |
| 9 | + |
| 10 | +jobs: |
| 11 | + auto-approve-codeflow: |
| 12 | + runs-on: ubuntu-latest |
| 13 | + if: github.event.pull_request.user.login == 'dotnet-maestro[bot]' |
| 14 | + steps: |
| 15 | + - name: Validate and auto-approve codeflow PR |
| 16 | + env: |
| 17 | + GH_TOKEN: ${{ github.token }} |
| 18 | + PR_NUMBER: ${{ github.event.pull_request.number }} |
| 19 | + GITHUB_REPOSITORY: ${{ github.repository }} |
| 20 | + run: | |
| 21 | + python3 - <<'PYTHON' |
| 22 | + import os, re, subprocess, sys |
| 23 | +
|
| 24 | + pr_number = os.environ["PR_NUMBER"] |
| 25 | + repo = os.environ["GITHUB_REPOSITORY"] |
| 26 | +
|
| 27 | + def gh(*args: str) -> str: |
| 28 | + result = subprocess.run( |
| 29 | + ["gh", *args, "--repo", repo], |
| 30 | + capture_output=True, text=True, check=True) |
| 31 | + return result.stdout |
| 32 | +
|
| 33 | + # ---- allowed files ------------------------------------------------ |
| 34 | + ALLOWED_FILES = { |
| 35 | + "eng/Version.Details.xml", |
| 36 | + "eng/Version.Details.props", |
| 37 | + "eng/Versions.props", |
| 38 | + "global.json", |
| 39 | + "NuGet.config", |
| 40 | + } |
| 41 | +
|
| 42 | + # ---- regex helpers ------------------------------------------------ |
| 43 | + VERSION = r'[0-9]+\.[0-9]+[A-Za-z0-9.\-+]*' |
| 44 | + SHA = r'[0-9a-fA-F]{7,40}' |
| 45 | + BARID = r'[0-9]+' |
| 46 | + URL = r'https://[^\s"<>]+' |
| 47 | +
|
| 48 | + # Patterns per file – every added/removed line must match at least one |
| 49 | + FILE_PATTERNS: dict[str, list[re.Pattern]] = { |
| 50 | + "eng/Version.Details.xml": [ |
| 51 | + re.compile(rf'^\s*<Source\s+Uri="{URL}"\s+Mapping="[^"]+"\s+Sha="{SHA}"\s+BarId="{BARID}"\s*/>\s*$'), |
| 52 | + re.compile(rf'^\s*<Sha>{SHA}</Sha>\s*$'), |
| 53 | + re.compile(rf'^\s*<Dependency\s+Name="[^"]+"\s+Version="{VERSION}">\s*$'), |
| 54 | + re.compile(rf'^\s*<Uri>{URL}</Uri>\s*$'), |
| 55 | + ], |
| 56 | + "eng/Version.Details.props": [ |
| 57 | + re.compile(rf'^\s*<[A-Za-z][A-Za-z0-9]*>{VERSION}</[A-Za-z][A-Za-z0-9]*>\s*$'), |
| 58 | + re.compile(r'^\s*<!--.*-->\s*$'), |
| 59 | + ], |
| 60 | + "eng/Versions.props": [ |
| 61 | + re.compile(rf'^\s*<[A-Za-z][A-Za-z0-9]*>{VERSION}</[A-Za-z][A-Za-z0-9]*>\s*$'), |
| 62 | + re.compile(r'^\s*<!--.*-->\s*$'), |
| 63 | + ], |
| 64 | + "global.json": [ |
| 65 | + re.compile(rf'^\s*"[^"]+"\s*:\s*"{VERSION}"\s*,?\s*$'), |
| 66 | + ], |
| 67 | + "NuGet.config": [ |
| 68 | + re.compile(rf'^\s*<add\s+key="darc-[^"]+"\s+value="{URL}"\s*/>\s*$'), |
| 69 | + ], |
| 70 | + } |
| 71 | +
|
| 72 | + # ---- validate the diff: files and content ------------------------- |
| 73 | + print(f"Validating codeflow PR #{pr_number}...") |
| 74 | +
|
| 75 | + diff_text = gh("pr", "diff", pr_number) |
| 76 | + current_file: str | None = None |
| 77 | + errors: list[str] = [] |
| 78 | +
|
| 79 | + for raw_line in diff_text.splitlines(): |
| 80 | + if raw_line.startswith("diff --git"): |
| 81 | + parts = raw_line.split(" b/") |
| 82 | + current_file = parts[-1] if len(parts) >= 2 else None |
| 83 | + if current_file and current_file not in ALLOWED_FILES: |
| 84 | + errors.append(f"Unexpected file: {current_file}") |
| 85 | + current_file = None |
| 86 | + continue |
| 87 | +
|
| 88 | + if raw_line.startswith(("---", "+++", "@@", "\\ No newline")): |
| 89 | + continue |
| 90 | +
|
| 91 | + if not raw_line.startswith(("+", "-")): |
| 92 | + continue |
| 93 | +
|
| 94 | + content = raw_line[1:] |
| 95 | + if not content.strip(): |
| 96 | + continue |
| 97 | +
|
| 98 | + if current_file is None: |
| 99 | + continue |
| 100 | +
|
| 101 | + patterns = FILE_PATTERNS.get(current_file) |
| 102 | + if patterns is None: |
| 103 | + errors.append(f"No validation patterns for file: {current_file}") |
| 104 | + continue |
| 105 | +
|
| 106 | + if not any(p.match(content) for p in patterns): |
| 107 | + errors.append(f"Unexpected change in {current_file}: {content.strip()}") |
| 108 | +
|
| 109 | + if errors: |
| 110 | + for e in errors: |
| 111 | + print(f"::notice::{e}") |
| 112 | + print("::notice::Skipping auto-approve – PR contains unexpected changes") |
| 113 | + sys.exit(0) |
| 114 | +
|
| 115 | + print("✔ All changes are expected dependency-update patterns") |
| 116 | +
|
| 117 | + # ---- approve the PR ------------------------------------------------ |
| 118 | + gh("pr", "review", pr_number, "--approve", |
| 119 | + "--body", "Auto-approved: codeflow dependency update PR with only version/SHA bumps in expected files.") |
| 120 | + print(f"✔ PR #{pr_number} approved") |
| 121 | + PYTHON |
0 commit comments