|
| 1 | +"""Automated NuGet dependency updater. |
| 2 | +
|
| 3 | +Discovers every `*.csproj` under the repo root (excluding `bin/`, `obj/`, |
| 4 | +`node_modules/` and any `EXCLUDED_PATH_PREFIXES`), reads each one's inline |
| 5 | +`<PackageReference ... Version="..."/>` entries, and atomically bumps every |
| 6 | +reference to the latest stable (non-pre-release) NuGet version. |
| 7 | +
|
| 8 | +Pre-release versions (any version containing `-`, per SemVer) are never |
| 9 | +installed. References currently pinned to a pre-release are left alone with a |
| 10 | +printed warning — fixing those is a manual decision. |
| 11 | +
|
| 12 | +On success: one GitHub issue summarizing every bump, one commit staging every |
| 13 | +modified csproj (never `git add .`), one patch-version tag, one push. |
| 14 | +
|
| 15 | +On `dotnet restore` failure: every modified csproj is rolled back via |
| 16 | +`git checkout` and the script exits non-zero without creating an issue, |
| 17 | +commit, or tag. |
| 18 | +""" |
| 19 | + |
| 20 | +import os |
| 21 | +import re |
| 22 | +import subprocess |
| 23 | +import sys |
| 24 | +from pathlib import Path |
| 25 | +from xml.etree import ElementTree as ET |
| 26 | + |
| 27 | +import requests |
| 28 | + |
| 29 | +GITHUB_REPO_OWNER = "microting" |
| 30 | +GITHUB_REPO_NAME = "eform-angular-frontend" |
| 31 | + |
| 32 | +EXCLUDED_PATH_PREFIXES = ["eFormAPI/Plugins"] |
| 33 | +EXCLUDED_DIR_NAMES = {"bin", "obj", "node_modules"} |
| 34 | + |
| 35 | +REPO_ROOT = Path(__file__).resolve().parent |
| 36 | +GITHUB_ACCESS_TOKEN = os.getenv("CHANGELOG_GITHUB_TOKEN") |
| 37 | + |
| 38 | + |
| 39 | +def discover_csprojs(): |
| 40 | + excluded_prefixes = tuple(p.rstrip("/") + "/" for p in EXCLUDED_PATH_PREFIXES) |
| 41 | + results = [] |
| 42 | + for path in REPO_ROOT.rglob("*.csproj"): |
| 43 | + rel = path.relative_to(REPO_ROOT) |
| 44 | + if any(part in EXCLUDED_DIR_NAMES for part in rel.parts): |
| 45 | + continue |
| 46 | + rel_str = rel.as_posix() |
| 47 | + if rel_str.startswith(excluded_prefixes): |
| 48 | + continue |
| 49 | + results.append(path) |
| 50 | + return sorted(results) |
| 51 | + |
| 52 | + |
| 53 | +def read_package_references(csproj_path): |
| 54 | + tree = ET.parse(csproj_path) |
| 55 | + refs = [] |
| 56 | + for pr in tree.getroot().iter("PackageReference"): |
| 57 | + name = pr.attrib.get("Include") |
| 58 | + version = pr.attrib.get("Version") |
| 59 | + if name and version: |
| 60 | + refs.append((name, version)) |
| 61 | + return refs |
| 62 | + |
| 63 | + |
| 64 | +def get_latest_stable_version(package_name): |
| 65 | + url = f"https://api.nuget.org/v3-flatcontainer/{package_name.lower()}/index.json" |
| 66 | + response = requests.get(url, timeout=30) |
| 67 | + if response.status_code != 200: |
| 68 | + return None |
| 69 | + stable = [v for v in response.json().get("versions", []) if "-" not in v] |
| 70 | + return stable[-1] if stable else None |
| 71 | + |
| 72 | + |
| 73 | +def update_csproj_versions(csproj_path, bumps): |
| 74 | + content = csproj_path.read_text(encoding="utf-8") |
| 75 | + for name, _old, new in bumps: |
| 76 | + pattern = re.compile( |
| 77 | + r'(<PackageReference\s+Include="' |
| 78 | + + re.escape(name) |
| 79 | + + r'"\s+Version=")[^"]+(")' |
| 80 | + ) |
| 81 | + content, n = pattern.subn(r"\g<1>" + new + r"\g<2>", content) |
| 82 | + if n == 0: |
| 83 | + raise RuntimeError( |
| 84 | + f"No PackageReference with inline Version attribute found for " |
| 85 | + f"{name} in {csproj_path}" |
| 86 | + ) |
| 87 | + csproj_path.write_text(content, encoding="utf-8") |
| 88 | + |
| 89 | + |
| 90 | +def rollback(csproj_paths): |
| 91 | + if not csproj_paths: |
| 92 | + return |
| 93 | + rel_paths = [str(p.relative_to(REPO_ROOT)) for p in csproj_paths] |
| 94 | + subprocess.run(["git", "checkout", "--", *rel_paths], check=True) |
| 95 | + |
| 96 | + |
| 97 | +def run_restore(): |
| 98 | + slns = sorted(REPO_ROOT.glob("*.sln")) |
| 99 | + if slns: |
| 100 | + return subprocess.run( |
| 101 | + ["dotnet", "restore", str(slns[0])], |
| 102 | + capture_output=True, |
| 103 | + text=True, |
| 104 | + ) |
| 105 | + last_failure = None |
| 106 | + for csproj in discover_csprojs(): |
| 107 | + result = subprocess.run( |
| 108 | + ["dotnet", "restore", str(csproj)], |
| 109 | + capture_output=True, |
| 110 | + text=True, |
| 111 | + ) |
| 112 | + if result.returncode != 0: |
| 113 | + last_failure = result |
| 114 | + if last_failure is not None: |
| 115 | + return last_failure |
| 116 | + return subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr="") |
| 117 | + |
| 118 | + |
| 119 | +def create_github_issue(bumps_by_csproj): |
| 120 | + total = sum(len(bs) for bs in bumps_by_csproj.values()) |
| 121 | + plural = "s" if total != 1 else "" |
| 122 | + title = f"Bump {total} NuGet package{plural}" |
| 123 | + lines = ["The following packages were updated:", ""] |
| 124 | + for csproj_rel, bumps in bumps_by_csproj.items(): |
| 125 | + lines.append(f"### `{csproj_rel}`") |
| 126 | + for name, old, new in bumps: |
| 127 | + lines.append(f"- `{name}`: {old} -> {new}") |
| 128 | + lines.append("") |
| 129 | + body = "\n".join(lines) |
| 130 | + |
| 131 | + headers = { |
| 132 | + "Authorization": f"Bearer {GITHUB_ACCESS_TOKEN}", |
| 133 | + "Accept": "application/vnd.github.v3+json", |
| 134 | + } |
| 135 | + response = requests.post( |
| 136 | + f"https://api.github.com/repos/{GITHUB_REPO_OWNER}/{GITHUB_REPO_NAME}/issues", |
| 137 | + headers=headers, |
| 138 | + json={"title": title, "body": body}, |
| 139 | + ) |
| 140 | + if response.status_code != 201: |
| 141 | + raise RuntimeError(f"Failed to create GitHub issue: {response.text}") |
| 142 | + issue_number = response.json()["number"] |
| 143 | + print(f"GitHub issue '{title}' created. Issue Number: {issue_number}") |
| 144 | + |
| 145 | + for label in (".Net", "backend", "enhancement"): |
| 146 | + label_response = requests.post( |
| 147 | + f"https://api.github.com/repos/{GITHUB_REPO_OWNER}/{GITHUB_REPO_NAME}/issues/{issue_number}/labels", |
| 148 | + headers=headers, |
| 149 | + json={"labels": [label]}, |
| 150 | + ) |
| 151 | + if label_response.status_code == 200: |
| 152 | + print(f"Label '{label}' added to the issue.") |
| 153 | + else: |
| 154 | + print(f"Failed to add label '{label}' to the issue.") |
| 155 | + return issue_number |
| 156 | + |
| 157 | + |
| 158 | +def commit_modified_csprojs(csproj_paths, issue_number): |
| 159 | + rel_paths = [str(p.relative_to(REPO_ROOT)) for p in csproj_paths] |
| 160 | + subprocess.run(["git", "add", *rel_paths], check=True) |
| 161 | + subprocess.run(["git", "commit", "-m", f"closes #{issue_number}"], check=True) |
| 162 | + |
| 163 | + |
| 164 | +def push_new_version_tag(): |
| 165 | + tags_output = ( |
| 166 | + subprocess.check_output(["git", "tag", "--sort=-creatordate"]) |
| 167 | + .decode("utf-8") |
| 168 | + .strip() |
| 169 | + ) |
| 170 | + if not tags_output: |
| 171 | + print("No tags found in the repository.") |
| 172 | + return |
| 173 | + latest = tags_output.splitlines()[0].lstrip("v") |
| 174 | + major, minor, build = map(int, latest.split(".")) |
| 175 | + new_tag = f"v{major}.{minor}.{build + 1}" |
| 176 | + print(f"Current Git Version: {latest}. Creating new tag {new_tag}.") |
| 177 | + subprocess.run(["git", "tag", new_tag], check=True) |
| 178 | + subprocess.run(["git", "push", "--tags"], check=True) |
| 179 | + subprocess.run(["git", "push"], check=True) |
| 180 | + |
| 181 | + |
| 182 | +def main(): |
| 183 | + commits_before = len( |
| 184 | + subprocess.check_output(["git", "log", "--oneline"]) |
| 185 | + .decode("utf-8") |
| 186 | + .splitlines() |
| 187 | + ) |
| 188 | + print("Current number of commits:", commits_before) |
| 189 | + |
| 190 | + csprojs = discover_csprojs() |
| 191 | + if not csprojs: |
| 192 | + print("No csproj files found in scope.") |
| 193 | + return |
| 194 | + print(f"Discovered {len(csprojs)} csproj(s) in scope:") |
| 195 | + for p in csprojs: |
| 196 | + print(f" {p.relative_to(REPO_ROOT)}") |
| 197 | + |
| 198 | + latest_cache = {} |
| 199 | + pre_release_pins = [] |
| 200 | + planned = {} |
| 201 | + |
| 202 | + for csproj in csprojs: |
| 203 | + for name, current in read_package_references(csproj): |
| 204 | + if "-" in current: |
| 205 | + pre_release_pins.append((csproj, name, current)) |
| 206 | + continue |
| 207 | + if name not in latest_cache: |
| 208 | + print(f"Checking {name}") |
| 209 | + latest_cache[name] = get_latest_stable_version(name) |
| 210 | + latest = latest_cache[name] |
| 211 | + if latest is None: |
| 212 | + print(f"Failed to retrieve package information for {name}.") |
| 213 | + continue |
| 214 | + if latest == current: |
| 215 | + continue |
| 216 | + planned.setdefault(csproj, []).append((name, current, latest)) |
| 217 | + |
| 218 | + for csproj, name, version in pre_release_pins: |
| 219 | + rel = csproj.relative_to(REPO_ROOT) |
| 220 | + print(f"Skipping {name} in {rel}: pinned to pre-release ({version}).") |
| 221 | + |
| 222 | + if not planned: |
| 223 | + print("Nothing to do, everything is up to date.") |
| 224 | + return |
| 225 | + |
| 226 | + print() |
| 227 | + print("Planned bumps:") |
| 228 | + for csproj, bumps in planned.items(): |
| 229 | + rel = csproj.relative_to(REPO_ROOT) |
| 230 | + print(f" {rel}") |
| 231 | + for name, old, new in bumps: |
| 232 | + print(f" {name}: {old} -> {new}") |
| 233 | + |
| 234 | + modified = [] |
| 235 | + try: |
| 236 | + for csproj, bumps in planned.items(): |
| 237 | + update_csproj_versions(csproj, bumps) |
| 238 | + modified.append(csproj) |
| 239 | + except Exception: |
| 240 | + rollback(modified) |
| 241 | + raise |
| 242 | + |
| 243 | + restore = run_restore() |
| 244 | + if restore.returncode != 0: |
| 245 | + print("dotnet restore failed after applying bumps. Rolling back.") |
| 246 | + print(restore.stdout) |
| 247 | + print(restore.stderr, file=sys.stderr) |
| 248 | + rollback(modified) |
| 249 | + sys.exit(1) |
| 250 | + |
| 251 | + bumps_by_csproj_rel = { |
| 252 | + str(csproj.relative_to(REPO_ROOT)): bumps |
| 253 | + for csproj, bumps in planned.items() |
| 254 | + } |
| 255 | + issue_number = create_github_issue(bumps_by_csproj_rel) |
| 256 | + commit_modified_csprojs(modified, issue_number) |
| 257 | + push_new_version_tag() |
| 258 | + |
| 259 | + |
| 260 | +if __name__ == "__main__": |
| 261 | + main() |
0 commit comments