| 1 | #!/usr/bin/env python3 |
| 2 | from __future__ import annotations |
| 3 | |
| 4 | import json |
| 5 | import os |
| 6 | import re |
| 7 | import subprocess |
| 8 | import sys |
| 9 | from dataclasses import asdict, dataclass |
| 10 | from pathlib import Path |
| 11 | from urllib.error import HTTPError, URLError |
| 12 | from urllib.parse import urlencode |
| 13 | from urllib.request import Request, urlopen |
| 14 | |
| 15 | |
| 16 | REPO_ROOT = Path(__file__).resolve().parents[2] |
| 17 | OPENROUTER_CHAT_COMPLETIONS_URL = "https://openrouter.ai/api/v1/chat/completions" |
| 18 | OPENROUTER_SYSTEM_PROMPT_PATH = REPO_ROOT / "scripts" / "openrouter_release_notes_system_prompt.md" |
| 19 | |
| 20 | |
| 21 | def fail(message: str) -> None: |
| 22 | print(message, file=sys.stderr) |
| 23 | raise SystemExit(1) |
| 24 | |
| 25 | |
| 26 | def write_output(name: str, value: str) -> None: |
| 27 | output_path = os.environ.get("GITHUB_OUTPUT") |
| 28 | if not output_path: |
| 29 | return |
| 30 | with open(output_path, "a", encoding="utf-8") as handle: |
| 31 | handle.write(f"{name}<<__EOF__\n{value}\n__EOF__\n") |
| 32 | |
| 33 | |
| 34 | def write_summary(lines: list[str]) -> None: |
| 35 | summary_path = os.environ.get("GITHUB_STEP_SUMMARY") |
| 36 | if not summary_path or not lines: |
| 37 | return |
| 38 | with open(summary_path, "a", encoding="utf-8") as handle: |
| 39 | handle.write("## Docker publish plan\n\n") |
| 40 | for line in lines: |
| 41 | handle.write(f"- {line}\n") |
| 42 | |
| 43 | |
| 44 | def run_command(*args: str, check: bool = True) -> subprocess.CompletedProcess[str]: |
| 45 | result = subprocess.run(args, capture_output=True, text=True) |
| 46 | if check and result.returncode != 0: |
| 47 | command = " ".join(args) |
| 48 | fail(f"Command failed ({command}):\n{result.stderr.strip()}") |
| 49 | return result |
| 50 | |
| 51 | |
| 52 | def git(*args: str, check: bool = True) -> str: |
| 53 | return run_command("git", *args, check=check).stdout.strip() |
| 54 | |
| 55 | |
| 56 | def docker_tag_exists(image_repo: str, tag: str) -> bool: |
| 57 | result = run_command( |
| 58 | "docker", |
| 59 | "buildx", |
| 60 | "imagetools", |
| 61 | "inspect", |
| 62 | f"{image_repo}:{tag}", |
| 63 | check=False, |
| 64 | ) |
| 65 | return result.returncode == 0 |
| 66 | |
| 67 | |
| 68 | def split_branches(raw: str) -> list[str]: |
| 69 | parts = re.split(r"[\s,]+", raw.strip()) |
| 70 | return [part for part in parts if part] |
| 71 | |
| 72 | |
| 73 | def require_env(name: str) -> str: |
| 74 | value = os.environ.get(name, "").strip() |
| 75 | if not value: |
| 76 | fail(f"Required environment variable `{name}` is missing.") |
| 77 | return value |
| 78 | |
| 79 | |
| 80 | def require_any_env(*names: str) -> str: |
| 81 | for name in names: |
| 82 | value = os.environ.get(name, "").strip() |
| 83 | if value: |
| 84 | return value |
| 85 | fail( |
| 86 | "Required environment variable is missing. Expected one of: " |
| 87 | + ", ".join(f"`{name}`" for name in names) |
| 88 | ) |
| 89 | |
| 90 | |
| 91 | @dataclass(frozen=True) |
| 92 | class Config: |
| 93 | allowed_branches: list[str] |
| 94 | main_branch: str |
| 95 | image_repo: str |
| 96 | tag_pattern: re.Pattern[str] |
| 97 | min_version: tuple[int, int] |
| 98 | event_name: str |
| 99 | source_ref_name: str |
| 100 | source_ref_type: str |
| 101 | manual_tag: str |
| 102 | before_sha: str |
| 103 | after_sha: str |
| 104 | |
| 105 | |
| 106 | @dataclass(frozen=True) |
| 107 | class BranchState: |
| 108 | branch: str |
| 109 | valid_tags: list[str] |
| 110 | latest_tag: str | None |
| 111 | |
| 112 | |
| 113 | @dataclass |
| 114 | class Candidate: |
| 115 | branch: str |
| 116 | source_tag: str |
| 117 | mode: str |
| 118 | publish_version: bool |
| 119 | publish_branch_tag: bool |
| 120 | reason: str |
| 121 | |
| 122 | |
| 123 | @dataclass(frozen=True) |
| 124 | class CommitEntry: |
| 125 | heading: str |
| 126 | description: str |
| 127 | |
| 128 | |
| 129 | def load_config() -> Config: |
| 130 | allowed_branches = split_branches(os.environ["ALLOWED_BRANCHES"]) |
| 131 | if not allowed_branches: |
| 132 | fail("ALLOWED_BRANCHES must not be empty.") |
| 133 | main_branch = os.environ["MAIN_BRANCH"].strip() |
| 134 | if main_branch not in allowed_branches: |
| 135 | fail("MAIN_BRANCH must also be listed in ALLOWED_BRANCHES.") |
| 136 | |
| 137 | tag_regex = os.environ["RELEASE_TAG_REGEX"] |
| 138 | return Config( |
| 139 | allowed_branches=allowed_branches, |
| 140 | main_branch=main_branch, |
| 141 | image_repo=os.environ["DOCKER_IMAGE_REPO"].strip(), |
| 142 | tag_pattern=re.compile(tag_regex), |
| 143 | min_version=( |
| 144 | int(os.environ["MIN_RELEASE_MAJOR"]), |
| 145 | int(os.environ["MIN_RELEASE_MINOR"]), |
| 146 | ), |
| 147 | event_name=os.environ["EVENT_NAME"].strip(), |
| 148 | source_ref_name=os.environ.get("SOURCE_REF_NAME", "").strip(), |
| 149 | source_ref_type=os.environ.get("SOURCE_REF_TYPE", "").strip(), |
| 150 | manual_tag=os.environ.get("MANUAL_TAG", "").strip(), |
| 151 | before_sha=os.environ.get("BEFORE_SHA", "").strip(), |
| 152 | after_sha=os.environ.get("AFTER_SHA", "").strip(), |
| 153 | ) |
| 154 | |
| 155 | |
| 156 | def parse_release_tag(config: Config, tag: str) -> tuple[int, int] | None: |
| 157 | match = config.tag_pattern.fullmatch(tag) |
| 158 | if not match: |
| 159 | return None |
| 160 | version = (int(match.group(1)), int(match.group(2))) |
| 161 | if version < config.min_version: |
| 162 | return None |
| 163 | return version |
| 164 | |
| 165 | |
| 166 | def tag_exists(tag: str) -> bool: |
| 167 | return run_command("git", "rev-parse", "--verify", "--quiet", f"refs/tags/{tag}", check=False).returncode == 0 |
| 168 | |
| 169 | |
| 170 | def tag_commit(tag: str) -> str: |
| 171 | return git("rev-list", "-n", "1", f"refs/tags/{tag}") |
| 172 | |
| 173 | |
| 174 | def commit_is_ancestor(older_ref: str, newer_ref: str) -> bool: |
| 175 | return ( |
| 176 | run_command( |
| 177 | "git", |
| 178 | "merge-base", |
| 179 | "--is-ancestor", |
| 180 | older_ref, |
| 181 | newer_ref, |
| 182 | check=False, |
| 183 | ).returncode |
| 184 | == 0 |
| 185 | ) |
| 186 | |
| 187 | |
| 188 | def branch_contains_commit(branch: str, commit: str) -> bool: |
| 189 | return ( |
| 190 | run_command( |
| 191 | "git", |
| 192 | "merge-base", |
| 193 | "--is-ancestor", |
| 194 | commit, |
| 195 | f"origin/{branch}", |
| 196 | check=False, |
| 197 | ).returncode |
| 198 | == 0 |
| 199 | ) |
| 200 | |
| 201 | |
| 202 | def ref_exists(ref: str) -> bool: |
| 203 | if not ref or re.fullmatch(r"0{40}", ref): |
| 204 | return False |
| 205 | return run_command("git", "rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}", check=False).returncode == 0 |
| 206 | |
| 207 | |
| 208 | def releasable_tags_for_ref(config: Config, ref: str) -> list[str]: |
| 209 | if not ref_exists(ref): |
| 210 | return [] |
| 211 | |
| 212 | tagged_versions: list[tuple[tuple[int, int], str]] = [] |
| 213 | merged_tags = git("tag", "--merged", ref) |
| 214 | for tag in merged_tags.splitlines(): |
| 215 | version = parse_release_tag(config, tag.strip()) |
| 216 | if version is None: |
| 217 | continue |
| 218 | tagged_versions.append((version, tag.strip())) |
| 219 | |
| 220 | tagged_versions.sort(key=lambda item: item[0]) |
| 221 | return [tag for _, tag in tagged_versions] |
| 222 | |
| 223 | |
| 224 | def latest_releasable_tag_for_ref(config: Config, ref: str) -> str | None: |
| 225 | valid_tags = releasable_tags_for_ref(config, ref) |
| 226 | return valid_tags[-1] if valid_tags else None |
| 227 | |
| 228 | |
| 229 | def collect_branch_states(config: Config, branches: list[str] | None = None) -> dict[str, BranchState]: |
| 230 | states: dict[str, BranchState] = {} |
| 231 | for branch in branches or config.allowed_branches: |
| 232 | if run_command("git", "show-ref", "--verify", "--quiet", f"refs/remotes/origin/{branch}", check=False).returncode != 0: |
| 233 | fail(f"Allowed branch origin/{branch} was not fetched.") |
| 234 | |
| 235 | valid_tags = releasable_tags_for_ref(config, f"origin/{branch}") |
| 236 | states[branch] = BranchState( |
| 237 | branch=branch, |
| 238 | valid_tags=valid_tags, |
| 239 | latest_tag=valid_tags[-1] if valid_tags else None, |
| 240 | ) |
| 241 | return states |
| 242 | |
| 243 | |
| 244 | def add_or_merge_candidate(candidates: dict[tuple[str, str, str], Candidate], candidate: Candidate) -> None: |
| 245 | key = (candidate.branch, candidate.source_tag, candidate.mode) |
| 246 | existing = candidates.get(key) |
| 247 | if existing is None: |
| 248 | candidates[key] = candidate |
| 249 | return |
| 250 | existing.publish_version = existing.publish_version or candidate.publish_version |
| 251 | existing.publish_branch_tag = existing.publish_branch_tag or candidate.publish_branch_tag |
| 252 | if candidate.reason not in existing.reason: |
| 253 | existing.reason = f"{existing.reason}; {candidate.reason}" |
| 254 | |
| 255 | |
| 256 | def plan_tag_push(config: Config, branch_states: dict[str, BranchState]) -> tuple[list[Candidate], list[str]]: |
| 257 | source_tag = config.source_ref_name |
| 258 | notes: list[str] = [] |
| 259 | version = parse_release_tag(config, source_tag) |
| 260 | if version is None: |
| 261 | return [], [f"Skipped `{source_tag}` because it does not match `v{{X}}.{{Y}}` or is below v{config.min_version[0]}.{config.min_version[1]}."] |
| 262 | if not tag_exists(source_tag): |
| 263 | return [], [f"Skipped `{source_tag}` because the tag is not present after checkout."] |
| 264 | |
| 265 | commit = tag_commit(source_tag) |
| 266 | candidates: list[Candidate] = [] |
| 267 | found_branch = False |
| 268 | for branch, state in branch_states.items(): |
| 269 | if not branch_contains_commit(branch, commit): |
| 270 | continue |
| 271 | found_branch = True |
| 272 | if state.latest_tag != source_tag: |
| 273 | notes.append( |
| 274 | f"Skipped `{source_tag}` on `{branch}` because `{state.latest_tag}` is the highest release tag currently reachable from that branch." |
| 275 | ) |
| 276 | continue |
| 277 | candidates.append( |
| 278 | Candidate( |
| 279 | branch=branch, |
| 280 | source_tag=source_tag, |
| 281 | mode="push_latest_only", |
| 282 | publish_version=branch == config.main_branch, |
| 283 | publish_branch_tag=True, |
| 284 | reason=f"Automatic build for the latest release tag on `{branch}`.", |
| 285 | ) |
| 286 | ) |
| 287 | |
| 288 | if not found_branch: |
| 289 | notes.append(f"Skipped `{source_tag}` because it is not reachable from any allowed branch.") |
| 290 | return candidates, notes |
| 291 | |
| 292 | |
| 293 | def plan_branch_push(config: Config, branch_states: dict[str, BranchState]) -> tuple[list[Candidate], list[str]]: |
| 294 | branch = config.source_ref_name |
| 295 | if branch not in branch_states: |
| 296 | return [], [f"Skipped `{branch}` because it is not an allowed release branch."] |
| 297 | |
| 298 | before_tag = latest_releasable_tag_for_ref(config, config.before_sha) |
| 299 | after_tag = branch_states[branch].latest_tag |
| 300 | if after_tag is None: |
| 301 | return [], [f"Skipped `{branch}` because it has no releasable tags."] |
| 302 | if before_tag == after_tag: |
| 303 | return [], [f"Skipped `{branch}` because its highest release tag is still `{after_tag}`."] |
| 304 | |
| 305 | return [ |
| 306 | Candidate( |
| 307 | branch=branch, |
| 308 | source_tag=after_tag, |
| 309 | mode="push_promoted_tag", |
| 310 | publish_version=branch == config.main_branch, |
| 311 | publish_branch_tag=True, |
| 312 | reason=f"Automatic build for `{after_tag}` after it reached `{branch}`.", |
| 313 | ) |
| 314 | ], [] |
| 315 | |
| 316 | |
| 317 | def plan_manual_exact(config: Config, branch_states: dict[str, BranchState]) -> tuple[list[Candidate], list[str]]: |
| 318 | manual_tag = config.manual_tag |
| 319 | if parse_release_tag(config, manual_tag) is None: |
| 320 | fail( |
| 321 | f"Manual tag `{manual_tag}` is invalid. Expected `v{{X}}.{{Y}}` with a minimum of v{config.min_version[0]}.{config.min_version[1]}." |
| 322 | ) |
| 323 | if not tag_exists(manual_tag): |
| 324 | fail(f"Manual tag `{manual_tag}` does not exist in the repository.") |
| 325 | |
| 326 | commit = tag_commit(manual_tag) |
| 327 | notes: list[str] = [] |
| 328 | candidates: list[Candidate] = [] |
| 329 | for branch, state in branch_states.items(): |
| 330 | if not branch_contains_commit(branch, commit): |
| 331 | continue |
| 332 | if branch == config.main_branch: |
| 333 | candidates.append( |
| 334 | Candidate( |
| 335 | branch=branch, |
| 336 | source_tag=manual_tag, |
| 337 | mode="manual_exact", |
| 338 | publish_version=True, |
| 339 | publish_branch_tag=state.latest_tag == manual_tag, |
| 340 | reason=f"Manual rebuild for `{manual_tag}` on `{branch}`.", |
| 341 | ) |
| 342 | ) |
| 343 | continue |
| 344 | if state.latest_tag != manual_tag: |
| 345 | notes.append( |
| 346 | f"Skipped `{manual_tag}` on `{branch}` because non-main branches only publish their current branch tag and `{state.latest_tag}` is newer." |
| 347 | ) |
| 348 | continue |
| 349 | candidates.append( |
| 350 | Candidate( |
| 351 | branch=branch, |
| 352 | source_tag=manual_tag, |
| 353 | mode="manual_exact", |
| 354 | publish_version=False, |
| 355 | publish_branch_tag=True, |
| 356 | reason=f"Manual rebuild for the current branch image on `{branch}`.", |
| 357 | ) |
| 358 | ) |
| 359 | |
| 360 | if not candidates: |
| 361 | notes.append(f"No eligible images were found for manual tag `{manual_tag}`.") |
| 362 | return candidates, notes |
| 363 | |
| 364 | |
| 365 | def plan_manual_backfill(config: Config, branch_states: dict[str, BranchState]) -> tuple[list[Candidate], list[str]]: |
| 366 | notes: list[str] = [] |
| 367 | candidates: dict[tuple[str, str, str], Candidate] = {} |
| 368 | |
| 369 | for branch, state in branch_states.items(): |
| 370 | if not state.valid_tags: |
| 371 | notes.append(f"Branch `{branch}` has no releasable tags.") |
| 372 | continue |
| 373 | |
| 374 | if branch == config.main_branch: |
| 375 | for tag in state.valid_tags: |
| 376 | if docker_tag_exists(config.image_repo, tag): |
| 377 | continue |
| 378 | add_or_merge_candidate( |
| 379 | candidates, |
| 380 | Candidate( |
| 381 | branch=branch, |
| 382 | source_tag=tag, |
| 383 | mode="manual_backfill", |
| 384 | publish_version=True, |
| 385 | publish_branch_tag=False, |
| 386 | reason=f"Missing Docker Hub tag `{tag}`.", |
| 387 | ), |
| 388 | ) |
| 389 | |
| 390 | latest_tag = state.latest_tag |
| 391 | if latest_tag and not docker_tag_exists(config.image_repo, "latest"): |
| 392 | add_or_merge_candidate( |
| 393 | candidates, |
| 394 | Candidate( |
| 395 | branch=branch, |
| 396 | source_tag=latest_tag, |
| 397 | mode="manual_backfill", |
| 398 | publish_version=False, |
| 399 | publish_branch_tag=True, |
| 400 | reason="Missing Docker Hub tag `latest`.", |
| 401 | ), |
| 402 | ) |
| 403 | continue |
| 404 | |
| 405 | if not docker_tag_exists(config.image_repo, branch): |
| 406 | add_or_merge_candidate( |
| 407 | candidates, |
| 408 | Candidate( |
| 409 | branch=branch, |
| 410 | source_tag=state.latest_tag, |
| 411 | mode="manual_backfill", |
| 412 | publish_version=False, |
| 413 | publish_branch_tag=True, |
| 414 | reason=f"Missing Docker Hub tag `{branch}`.", |
| 415 | ), |
| 416 | ) |
| 417 | |
| 418 | if not candidates: |
| 419 | notes.append("No missing Docker Hub tags were found.") |
| 420 | return list(candidates.values()), notes |
| 421 | |
| 422 | |
| 423 | def plan_command() -> None: |
| 424 | config = load_config() |
| 425 | branch_states = collect_branch_states(config) |
| 426 | |
| 427 | if config.event_name == "workflow_dispatch": |
| 428 | if config.manual_tag: |
| 429 | candidates, notes = plan_manual_exact(config, branch_states) |
| 430 | else: |
| 431 | candidates, notes = plan_manual_backfill(config, branch_states) |
| 432 | elif config.event_name == "push": |
| 433 | if config.source_ref_type == "tag": |
| 434 | candidates, notes = plan_tag_push(config, branch_states) |
| 435 | elif config.source_ref_type == "branch": |
| 436 | candidates, notes = plan_branch_push(config, branch_states) |
| 437 | else: |
| 438 | fail(f"Unsupported push ref type: {config.source_ref_type}") |
| 439 | else: |
| 440 | fail(f"Unsupported event: {config.event_name}") |
| 441 | |
| 442 | summary_lines = [candidate.reason for candidate in candidates] |
| 443 | summary_lines.extend(notes) |
| 444 | |
| 445 | matrix = {"include": [asdict(candidate) for candidate in candidates]} |
| 446 | write_output("has_work", "true" if candidates else "false") |
| 447 | write_output("matrix", json.dumps(matrix)) |
| 448 | write_summary(summary_lines) |
| 449 | |
| 450 | print(json.dumps(matrix, indent=2)) |
| 451 | for line in summary_lines: |
| 452 | print(f"- {line}") |
| 453 | |
| 454 | |
| 455 | def unique(items: list[str]) -> list[str]: |
| 456 | seen: set[str] = set() |
| 457 | output: list[str] = [] |
| 458 | for item in items: |
| 459 | if item in seen: |
| 460 | continue |
| 461 | seen.add(item) |
| 462 | output.append(item) |
| 463 | return output |
| 464 | |
| 465 | |
| 466 | def load_text(path: Path) -> str: |
| 467 | if not path.exists(): |
| 468 | fail(f"Expected file `{path}` to exist.") |
| 469 | return path.read_text(encoding="utf-8").strip() |
| 470 | |
| 471 | |
| 472 | def github_repository_parts() -> tuple[str, str]: |
| 473 | repository = require_env("GITHUB_REPOSITORY") |
| 474 | owner, separator, repo = repository.partition("/") |
| 475 | if not owner or not separator or not repo: |
| 476 | fail(f"GITHUB_REPOSITORY must be in `owner/repo` format, got `{repository}`.") |
| 477 | return owner, repo |
| 478 | |
| 479 | |
| 480 | def github_api_get(path: str, params: dict[str, str | int] | None = None) -> object: |
| 481 | api_base = os.environ.get("GITHUB_API_URL", "https://api.github.com").rstrip("/") |
| 482 | token = require_env("GITHUB_TOKEN") |
| 483 | query = f"?{urlencode(params)}" if params else "" |
| 484 | request = Request( |
| 485 | f"{api_base}{path}{query}", |
| 486 | headers={ |
| 487 | "Accept": "application/vnd.github+json", |
| 488 | "Authorization": f"Bearer {token}", |
| 489 | "X-GitHub-Api-Version": "2022-11-28", |
| 490 | }, |
| 491 | method="GET", |
| 492 | ) |
| 493 | |
| 494 | try: |
| 495 | with urlopen(request, timeout=30) as response: |
| 496 | return json.loads(response.read().decode("utf-8")) |
| 497 | except HTTPError as exc: |
| 498 | details = exc.read().decode("utf-8", errors="replace").strip() |
| 499 | fail(f"GitHub API request failed ({path}): {exc.code} {exc.reason}\n{details}") |
| 500 | except URLError as exc: |
| 501 | fail(f"GitHub API request failed ({path}): {exc.reason}") |
| 502 | |
| 503 | |
| 504 | def list_github_releases() -> list[dict[str, object]]: |
| 505 | owner, repo = github_repository_parts() |
| 506 | releases: list[dict[str, object]] = [] |
| 507 | page = 1 |
| 508 | |
| 509 | while True: |
| 510 | payload = github_api_get( |
| 511 | f"/repos/{owner}/{repo}/releases", |
| 512 | {"per_page": 100, "page": page}, |
| 513 | ) |
| 514 | if not isinstance(payload, list): |
| 515 | fail("GitHub releases response was not a list.") |
| 516 | page_items = [item for item in payload if isinstance(item, dict)] |
| 517 | releases.extend(page_items) |
| 518 | if len(page_items) < 100: |
| 519 | break |
| 520 | page += 1 |
| 521 | |
| 522 | return releases |
| 523 | |
| 524 | |
| 525 | def previous_published_release_tag(config: Config, source_tag: str) -> str | None: |
| 526 | source_version = parse_release_tag(config, source_tag) |
| 527 | if source_version is None: |
| 528 | fail(f"Tag `{source_tag}` is not a releasable tag.") |
| 529 | |
| 530 | previous: list[tuple[tuple[int, int], str]] = [] |
| 531 | for release in list_github_releases(): |
| 532 | if release.get("draft") or release.get("prerelease"): |
| 533 | continue |
| 534 | tag_name = str(release.get("tag_name", "")).strip() |
| 535 | version = parse_release_tag(config, tag_name) |
| 536 | if version is None or version >= source_version: |
| 537 | continue |
| 538 | previous.append((version, tag_name)) |
| 539 | |
| 540 | previous.sort(key=lambda item: item[0]) |
| 541 | return previous[-1][1] if previous else None |
| 542 | |
| 543 | |
| 544 | def parse_commit_entries(raw_log: str) -> list[CommitEntry]: |
| 545 | entries: list[CommitEntry] = [] |
| 546 | for raw_entry in raw_log.split("\x1e"): |
| 547 | entry = raw_entry.strip() |
| 548 | if not entry: |
| 549 | continue |
| 550 | heading, separator, description = entry.partition("\x1f") |
| 551 | if not separator: |
| 552 | continue |
| 553 | entries.append( |
| 554 | CommitEntry( |
| 555 | heading=re.sub(r"\s+", " ", heading).strip(), |
| 556 | description=description.strip(), |
| 557 | ) |
| 558 | ) |
| 559 | return entries |
| 560 | |
| 561 | |
| 562 | def collect_release_commits(previous_release_tag: str | None, source_tag: str) -> list[CommitEntry]: |
| 563 | range_ref = source_tag |
| 564 | if previous_release_tag: |
| 565 | if not tag_exists(previous_release_tag): |
| 566 | fail(f"Previous published release tag `{previous_release_tag}` is not available in the repository.") |
| 567 | if not commit_is_ancestor( |
| 568 | f"refs/tags/{previous_release_tag}^{{commit}}", |
| 569 | f"refs/tags/{source_tag}^{{commit}}", |
| 570 | ): |
| 571 | fail( |
| 572 | f"Previous published release tag `{previous_release_tag}` is not an ancestor of `{source_tag}`." |
| 573 | ) |
| 574 | range_ref = f"{previous_release_tag}..{source_tag}" |
| 575 | |
| 576 | raw_log = git("log", "--reverse", "--format=%s%x1f%b%x1e", range_ref) |
| 577 | return parse_commit_entries(raw_log) |
| 578 | |
| 579 | |
| 580 | def build_release_notes_user_message(commits: list[CommitEntry]) -> str: |
| 581 | lines = ["Commit headings and descriptions:"] |
| 582 | |
| 583 | if not commits: |
| 584 | lines.append("No commits were found in this release range.") |
| 585 | return "\n".join(lines) |
| 586 | |
| 587 | for index, commit in enumerate(commits, start=1): |
| 588 | lines.append(f"{index}. Heading: {commit.heading}") |
| 589 | if commit.description: |
| 590 | lines.append("Description:") |
| 591 | lines.append(commit.description) |
| 592 | else: |
| 593 | lines.append("Description: (none)") |
| 594 | lines.append("") |
| 595 | |
| 596 | return "\n".join(lines).strip() |
| 597 | |
| 598 | |
| 599 | def extract_openrouter_message_content(payload: object) -> str: |
| 600 | if not isinstance(payload, dict): |
| 601 | return "" |
| 602 | |
| 603 | content = payload.get("content") |
| 604 | if isinstance(content, str): |
| 605 | return content |
| 606 | if not isinstance(content, list): |
| 607 | return "" |
| 608 | |
| 609 | parts: list[str] = [] |
| 610 | for part in content: |
| 611 | if not isinstance(part, dict): |
| 612 | continue |
| 613 | text = part.get("text") |
| 614 | if isinstance(text, str): |
| 615 | parts.append(text) |
| 616 | return "\n".join(parts) |
| 617 | |
| 618 | |
| 619 | def generate_release_body_with_openrouter(commits: list[CommitEntry]) -> str: |
| 620 | api_key = require_env("OPENROUTER_API_KEY") |
| 621 | model = require_any_env("OPENROUTER_MODEL_NAME", "OPENROUTER_MODEL") |
| 622 | system_prompt = load_text(OPENROUTER_SYSTEM_PROMPT_PATH) |
| 623 | repository = require_env("GITHUB_REPOSITORY") |
| 624 | user_message = build_release_notes_user_message(commits) |
| 625 | |
| 626 | payload = { |
| 627 | "model": model, |
| 628 | "messages": [ |
| 629 | {"role": "system", "content": system_prompt}, |
| 630 | {"role": "user", "content": user_message}, |
| 631 | ], |
| 632 | "temperature": 0.2, |
| 633 | } |
| 634 | request = Request( |
| 635 | OPENROUTER_CHAT_COMPLETIONS_URL, |
| 636 | data=json.dumps(payload).encode("utf-8"), |
| 637 | headers={ |
| 638 | "Authorization": f"Bearer {api_key}", |
| 639 | "Content-Type": "application/json", |
| 640 | "HTTP-Referer": f"https://github.com/{repository}", |
| 641 | "X-OpenRouter-Title": "Agent Zero Docker Release Notes", |
| 642 | }, |
| 643 | method="POST", |
| 644 | ) |
| 645 | |
| 646 | try: |
| 647 | with urlopen(request, timeout=60) as response: |
| 648 | response_payload = json.loads(response.read().decode("utf-8")) |
| 649 | except HTTPError as exc: |
| 650 | details = exc.read().decode("utf-8", errors="replace").strip() |
| 651 | fail(f"OpenRouter request failed: {exc.code} {exc.reason}\n{details}") |
| 652 | except URLError as exc: |
| 653 | fail(f"OpenRouter request failed: {exc.reason}") |
| 654 | |
| 655 | if not isinstance(response_payload, dict): |
| 656 | fail("OpenRouter response was not a JSON object.") |
| 657 | |
| 658 | choices = response_payload.get("choices") |
| 659 | if not isinstance(choices, list) or not choices: |
| 660 | fail(f"OpenRouter response did not include choices: {json.dumps(response_payload)}") |
| 661 | |
| 662 | first_choice = choices[0] |
| 663 | if not isinstance(first_choice, dict): |
| 664 | fail("OpenRouter returned an invalid choice payload.") |
| 665 | |
| 666 | message = first_choice.get("message") |
| 667 | body = extract_openrouter_message_content(message).strip() |
| 668 | return body or "No release notes." |
| 669 | |
| 670 | |
| 671 | def resolve_release_command() -> None: |
| 672 | config = load_config() |
| 673 | branch = os.environ["TARGET_BRANCH"].strip() |
| 674 | source_tag = os.environ["TARGET_TAG"].strip() |
| 675 | |
| 676 | if branch != config.main_branch: |
| 677 | write_output("should_release", "false") |
| 678 | write_output("skip_reason", f"Branch `{branch}` does not publish GitHub releases.") |
| 679 | return |
| 680 | |
| 681 | branch_state = collect_branch_states(config, [branch])[branch] |
| 682 | if branch_state.latest_tag is None: |
| 683 | write_output("should_release", "false") |
| 684 | write_output("skip_reason", f"Branch `{branch}` has no releasable tags.") |
| 685 | return |
| 686 | |
| 687 | if parse_release_tag(config, source_tag) is None or not tag_exists(source_tag): |
| 688 | write_output("should_release", "false") |
| 689 | write_output("skip_reason", f"Tag `{source_tag}` is not a releasable tag.") |
| 690 | return |
| 691 | |
| 692 | commit = tag_commit(source_tag) |
| 693 | if not branch_contains_commit(branch, commit): |
| 694 | write_output("should_release", "false") |
| 695 | write_output("skip_reason", f"Tag `{source_tag}` is no longer reachable from `{branch}`.") |
| 696 | return |
| 697 | |
| 698 | if branch_state.latest_tag != source_tag: |
| 699 | write_output("should_release", "false") |
| 700 | write_output( |
| 701 | "skip_reason", |
| 702 | f"Tag `{source_tag}` is not the highest release tag on `{branch}`.", |
| 703 | ) |
| 704 | return |
| 705 | |
| 706 | previous_release_tag = "" |
| 707 | commits: list[CommitEntry] = [] |
| 708 | body = "Failed to generate release notes." |
| 709 | try: |
| 710 | previous_release_tag = previous_published_release_tag(config, source_tag) or "" |
| 711 | commits = collect_release_commits(previous_release_tag or None, source_tag) |
| 712 | body = generate_release_body_with_openrouter(commits) |
| 713 | except SystemExit: |
| 714 | print( |
| 715 | f"Release note generation failed for `{source_tag}`. Falling back to a static release body.", |
| 716 | file=sys.stderr, |
| 717 | ) |
| 718 | except Exception as exc: |
| 719 | print( |
| 720 | f"Unexpected release note generation error for `{source_tag}`: {exc}. Falling back to a static release body.", |
| 721 | file=sys.stderr, |
| 722 | ) |
| 723 | |
| 724 | write_output("should_release", "true") |
| 725 | write_output("release_tag", source_tag) |
| 726 | write_output("release_name", source_tag) |
| 727 | write_output("previous_release_tag", previous_release_tag) |
| 728 | write_output("release_commit_count", str(len(commits))) |
| 729 | write_output("release_body", body) |
| 730 | print(source_tag) |
| 731 | |
| 732 | |
| 733 | def resolve_build_command() -> None: |
| 734 | config = load_config() |
| 735 | branch = os.environ["TARGET_BRANCH"].strip() |
| 736 | source_tag = os.environ["TARGET_TAG"].strip() |
| 737 | mode = os.environ["TARGET_MODE"].strip() |
| 738 | publish_version = os.environ["TARGET_PUBLISH_VERSION"].strip().lower() == "true" |
| 739 | publish_branch_tag = os.environ["TARGET_PUBLISH_BRANCH_TAG"].strip().lower() == "true" |
| 740 | |
| 741 | branch_state = collect_branch_states(config, [branch])[branch] |
| 742 | if branch_state.latest_tag is None: |
| 743 | write_output("should_build", "false") |
| 744 | write_output("skip_reason", f"Branch `{branch}` has no releasable tags.") |
| 745 | return |
| 746 | |
| 747 | if parse_release_tag(config, source_tag) is None or not tag_exists(source_tag): |
| 748 | write_output("should_build", "false") |
| 749 | write_output("skip_reason", f"Tag `{source_tag}` is no longer available.") |
| 750 | return |
| 751 | |
| 752 | commit = tag_commit(source_tag) |
| 753 | if not branch_contains_commit(branch, commit): |
| 754 | write_output("should_build", "false") |
| 755 | write_output("skip_reason", f"Tag `{source_tag}` is no longer reachable from `{branch}`.") |
| 756 | return |
| 757 | |
| 758 | mutable_tag = "latest" if branch == config.main_branch else branch |
| 759 | tags_to_push: list[str] = [] |
| 760 | |
| 761 | if mode == "push_latest_only": |
| 762 | if branch_state.latest_tag != source_tag: |
| 763 | write_output("should_build", "false") |
| 764 | write_output( |
| 765 | "skip_reason", |
| 766 | f"Tag `{source_tag}` is no longer the highest release tag on `{branch}`.", |
| 767 | ) |
| 768 | return |
| 769 | if publish_version: |
| 770 | tags_to_push.append(f"{config.image_repo}:{source_tag}") |
| 771 | if publish_branch_tag: |
| 772 | tags_to_push.append(f"{config.image_repo}:{mutable_tag}") |
| 773 | |
| 774 | elif mode == "push_promoted_tag": |
| 775 | if branch_state.latest_tag != source_tag: |
| 776 | write_output("should_build", "false") |
| 777 | write_output( |
| 778 | "skip_reason", |
| 779 | f"Tag `{source_tag}` is no longer the highest release tag on `{branch}`.", |
| 780 | ) |
| 781 | return |
| 782 | if publish_version and not docker_tag_exists(config.image_repo, source_tag): |
| 783 | tags_to_push.append(f"{config.image_repo}:{source_tag}") |
| 784 | if publish_branch_tag: |
| 785 | tags_to_push.append(f"{config.image_repo}:{mutable_tag}") |
| 786 | |
| 787 | elif mode == "manual_exact": |
| 788 | if publish_version: |
| 789 | tags_to_push.append(f"{config.image_repo}:{source_tag}") |
| 790 | if publish_branch_tag and branch_state.latest_tag == source_tag: |
| 791 | tags_to_push.append(f"{config.image_repo}:{mutable_tag}") |
| 792 | |
| 793 | elif mode == "manual_backfill": |
| 794 | if publish_version and not docker_tag_exists(config.image_repo, source_tag): |
| 795 | tags_to_push.append(f"{config.image_repo}:{source_tag}") |
| 796 | if publish_branch_tag: |
| 797 | if branch != config.main_branch and branch_state.latest_tag != source_tag: |
| 798 | write_output("should_build", "false") |
| 799 | write_output( |
| 800 | "skip_reason", |
| 801 | f"Tag `{source_tag}` is no longer the newest release tag on `{branch}`.", |
| 802 | ) |
| 803 | return |
| 804 | if branch == config.main_branch and branch_state.latest_tag != source_tag: |
| 805 | publish_branch_tag = False |
| 806 | if publish_branch_tag and not docker_tag_exists(config.image_repo, mutable_tag): |
| 807 | tags_to_push.append(f"{config.image_repo}:{mutable_tag}") |
| 808 | else: |
| 809 | fail(f"Unsupported resolve-build mode: {mode}") |
| 810 | |
| 811 | tags_to_push = unique(tags_to_push) |
| 812 | if not tags_to_push: |
| 813 | write_output("should_build", "false") |
| 814 | write_output("skip_reason", "All requested Docker tags already exist or are no longer eligible.") |
| 815 | return |
| 816 | |
| 817 | write_output("should_build", "true") |
| 818 | write_output("tags", "\n".join(tags_to_push)) |
| 819 | write_output("display_tags", ", ".join(tag.rsplit(":", 1)[1] for tag in tags_to_push)) |
| 820 | print("\n".join(tags_to_push)) |
| 821 | |
| 822 | |
| 823 | def main() -> None: |
| 824 | if len(sys.argv) != 2: |
| 825 | fail("Usage: docker_release_plan.py <plan|resolve-build|resolve-release>") |
| 826 | |
| 827 | command = sys.argv[1] |
| 828 | if command == "plan": |
| 829 | plan_command() |
| 830 | return |
| 831 | if command == "resolve-build": |
| 832 | resolve_build_command() |
| 833 | return |
| 834 | if command == "resolve-release": |
| 835 | resolve_release_command() |
| 836 | return |
| 837 | fail(f"Unknown command: {command}") |
| 838 | |
| 839 | |
| 840 | if __name__ == "__main__": |
| 841 | main() |