main
py 841 lines 29 KB
Raw
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()