Add automatic Docker builds when release tags reach testing/main branches

Extend docker_release_plan.py to detect when a new release tag becomes the highest tag on testing or main branches via push events. Track before/after SHAs to compare tag states and trigger builds for newly promoted tags. Add push_promoted_tag mode alongside existing tag push handling. Update workflow to trigger on branch pushes and pass ref type, before/after SHAs to planning script.

frdel committed Mar 26, 2026 at 08:22 UTC ce295c95db563e54200d67fcf53b60dc07a49e2b
3 files changed +220 -18
.github/scripts/docker_release_plan.py
+81 -15
@@ -69,8 +69,11 @@ class Config:
69 tag_pattern: re.Pattern[str]
70 min_version: tuple[int, int]
71 event_name: str
72 - source_tag: str
72 + source_ref_name: str
73 + source_ref_type: str
74 manual_tag: str
75 + before_sha: str
76 + after_sha: str
77
78
79 @dataclass(frozen=True)
@@ -109,8 +112,11 @@ def load_config() -> Config:
112 int(os.environ["MIN_RELEASE_MINOR"]),
113 ),
114 event_name=os.environ["EVENT_NAME"].strip(),
112 - source_tag=os.environ.get("SOURCE_TAG", "").strip(),
115 + source_ref_name=os.environ.get("SOURCE_REF_NAME", "").strip(),
116 + source_ref_type=os.environ.get("SOURCE_REF_TYPE", "").strip(),
117 manual_tag=os.environ.get("MANUAL_TAG", "").strip(),
118 + before_sha=os.environ.get("BEFORE_SHA", "").strip(),
119 + after_sha=os.environ.get("AFTER_SHA", "").strip(),
120 )
121
122
@@ -146,22 +152,40 @@ def branch_contains_commit(branch: str, commit: str) -> bool:
152 )
153
154
155 +def ref_exists(ref: str) -> bool:
156 + if not ref or re.fullmatch(r"0{40}", ref):
157 + return False
158 + return run_command("git", "rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}", check=False).returncode == 0
159 +
160 +
161 +def releasable_tags_for_ref(config: Config, ref: str) -> list[str]:
162 + if not ref_exists(ref):
163 + return []
164 +
165 + tagged_versions: list[tuple[tuple[int, int], str]] = []
166 + merged_tags = git("tag", "--merged", ref)
167 + for tag in merged_tags.splitlines():
168 + version = parse_release_tag(config, tag.strip())
169 + if version is None:
170 + continue
171 + tagged_versions.append((version, tag.strip()))
172 +
173 + tagged_versions.sort(key=lambda item: item[0])
174 + return [tag for _, tag in tagged_versions]
175 +
176 +
177 +def latest_releasable_tag_for_ref(config: Config, ref: str) -> str | None:
178 + valid_tags = releasable_tags_for_ref(config, ref)
179 + return valid_tags[-1] if valid_tags else None
180 +
181 +
182 def collect_branch_states(config: Config, branches: list[str] | None = None) -> dict[str, BranchState]:
183 states: dict[str, BranchState] = {}
184 for branch in branches or config.allowed_branches:
185 if run_command("git", "show-ref", "--verify", "--quiet", f"refs/remotes/origin/{branch}", check=False).returncode != 0:
186 fail(f"Allowed branch origin/{branch} was not fetched.")
187
155 - tagged_versions: list[tuple[tuple[int, int], str]] = []
156 - merged_tags = git("tag", "--merged", f"origin/{branch}")
157 - for tag in merged_tags.splitlines():
158 - version = parse_release_tag(config, tag.strip())
159 - if version is None:
160 - continue
161 - tagged_versions.append((version, tag.strip()))
162 -
163 - tagged_versions.sort(key=lambda item: item[0])
164 - valid_tags = [tag for _, tag in tagged_versions]
188 + valid_tags = releasable_tags_for_ref(config, f"origin/{branch}")
189 states[branch] = BranchState(
190 branch=branch,
191 valid_tags=valid_tags,
@@ -182,8 +206,8 @@ def add_or_merge_candidate(candidates: dict[tuple[str, str, str], Candidate], ca
206 existing.reason = f"{existing.reason}; {candidate.reason}"
207
208
185 -def plan_push(config: Config, branch_states: dict[str, BranchState]) -> tuple[list[Candidate], list[str]]:
186 - source_tag = config.source_tag
209 +def plan_tag_push(config: Config, branch_states: dict[str, BranchState]) -> tuple[list[Candidate], list[str]]:
210 + source_tag = config.source_ref_name
211 notes: list[str] = []
212 version = parse_release_tag(config, source_tag)
213 if version is None:
@@ -219,6 +243,30 @@ def plan_push(config: Config, branch_states: dict[str, BranchState]) -> tuple[li
243 return candidates, notes
244
245
246 +def plan_branch_push(config: Config, branch_states: dict[str, BranchState]) -> tuple[list[Candidate], list[str]]:
247 + branch = config.source_ref_name
248 + if branch not in branch_states:
249 + return [], [f"Skipped `{branch}` because it is not an allowed release branch."]
250 +
251 + before_tag = latest_releasable_tag_for_ref(config, config.before_sha)
252 + after_tag = branch_states[branch].latest_tag
253 + if after_tag is None:
254 + return [], [f"Skipped `{branch}` because it has no releasable tags."]
255 + if before_tag == after_tag:
256 + return [], [f"Skipped `{branch}` because its highest release tag is still `{after_tag}`."]
257 +
258 + return [
259 + Candidate(
260 + branch=branch,
261 + source_tag=after_tag,
262 + mode="push_promoted_tag",
263 + publish_version=branch == config.main_branch,
264 + publish_branch_tag=True,
265 + reason=f"Automatic build for `{after_tag}` after it reached `{branch}`.",
266 + )
267 + ], []
268 +
269 +
270 def plan_manual_exact(config: Config, branch_states: dict[str, BranchState]) -> tuple[list[Candidate], list[str]]:
271 manual_tag = config.manual_tag
272 if parse_release_tag(config, manual_tag) is None:
@@ -335,7 +383,12 @@ def plan_command() -> None:
383 else:
384 candidates, notes = plan_manual_backfill(config, branch_states)
385 elif config.event_name == "push":
338 - candidates, notes = plan_push(config, branch_states)
386 + if config.source_ref_type == "tag":
387 + candidates, notes = plan_tag_push(config, branch_states)
388 + elif config.source_ref_type == "branch":
389 + candidates, notes = plan_branch_push(config, branch_states)
390 + else:
391 + fail(f"Unsupported push ref type: {config.source_ref_type}")
392 else:
393 fail(f"Unsupported event: {config.event_name}")
394
@@ -457,6 +510,19 @@ def resolve_build_command() -> None:
510 if publish_branch_tag:
511 tags_to_push.append(f"{config.image_repo}:{mutable_tag}")
512
513 + elif mode == "push_promoted_tag":
514 + if branch_state.latest_tag != source_tag:
515 + write_output("should_build", "false")
516 + write_output(
517 + "skip_reason",
518 + f"Tag `{source_tag}` is no longer the highest release tag on `{branch}`.",
519 + )
520 + return
521 + if publish_version and not docker_tag_exists(config.image_repo, source_tag):
522 + tags_to_push.append(f"{config.image_repo}:{source_tag}")
523 + if publish_branch_tag:
524 + tags_to_push.append(f"{config.image_repo}:{mutable_tag}")
525 +
526 elif mode == "manual_exact":
527 if publish_version:
528 tags_to_push.append(f"{config.image_repo}:{source_tag}")
.github/workflows/docker-publish.yml
+16 -3
@@ -2,6 +2,9 @@ name: Build And Publish Docker Images
2
3 on:
4 push:
5 + branches:
6 + - "testing"
7 + - "main"
8 tags:
9 - "v*"
10 workflow_dispatch:
@@ -66,7 +69,10 @@ jobs:
69 id: plan
70 env:
71 EVENT_NAME: ${{ github.event_name }}
69 - SOURCE_TAG: ${{ github.ref_name }}
72 + SOURCE_REF_NAME: ${{ github.ref_name }}
73 + SOURCE_REF_TYPE: ${{ github.ref_type }}
74 + BEFORE_SHA: ${{ github.event_name == 'push' && github.event.before || '' }}
75 + AFTER_SHA: ${{ github.event_name == 'push' && github.sha || '' }}
76 MANUAL_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || '' }}
77 DOCKER_IMAGE_REPO: ${{ format('{0}/{1}', secrets.DOCKERHUB_ORG, env.DOCKER_IMAGE_NAME) }}
78 run: python3 .github/scripts/docker_release_plan.py plan
@@ -88,6 +94,7 @@ jobs:
94 uses: actions/checkout@v4
95 with:
96 fetch-depth: 0
97 + ref: ${{ matrix.source_tag }}
98
99 - name: Fetch remote branches and tags
100 run: git fetch --force --tags origin '+refs/heads/*:refs/remotes/origin/*'
@@ -118,7 +125,10 @@ jobs:
125 id: resolve
126 env:
127 EVENT_NAME: ${{ github.event_name }}
121 - SOURCE_TAG: ${{ github.ref_name }}
128 + SOURCE_REF_NAME: ${{ github.ref_name }}
129 + SOURCE_REF_TYPE: ${{ github.ref_type }}
130 + BEFORE_SHA: ${{ github.event_name == 'push' && github.event.before || '' }}
131 + AFTER_SHA: ${{ github.event_name == 'push' && github.sha || '' }}
132 MANUAL_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || '' }}
133 DOCKER_IMAGE_REPO: ${{ format('{0}/{1}', secrets.DOCKERHUB_ORG, env.DOCKER_IMAGE_NAME) }}
134 TARGET_BRANCH: ${{ matrix.branch }}
@@ -155,7 +165,10 @@ jobs:
165 id: release_plan
166 env:
167 EVENT_NAME: ${{ github.event_name }}
158 - SOURCE_TAG: ${{ github.ref_name }}
168 + SOURCE_REF_NAME: ${{ github.ref_name }}
169 + SOURCE_REF_TYPE: ${{ github.ref_type }}
170 + BEFORE_SHA: ${{ github.event_name == 'push' && github.event.before || '' }}
171 + AFTER_SHA: ${{ github.event_name == 'push' && github.sha || '' }}
172 MANUAL_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || '' }}
173 DOCKER_IMAGE_REPO: ${{ format('{0}/{1}', secrets.DOCKERHUB_ORG, env.DOCKER_IMAGE_NAME) }}
174 TARGET_BRANCH: ${{ matrix.branch }}
tests/test_docker_release_plan.py new
+123
@@ -0,0 +1,123 @@
1 +import importlib.util
2 +import subprocess
3 +import sys
4 +from pathlib import Path
5 +
6 +
7 +PROJECT_ROOT = Path(__file__).resolve().parents[1]
8 +MODULE_PATH = PROJECT_ROOT / ".github" / "scripts" / "docker_release_plan.py"
9 +
10 +
11 +def load_module():
12 + spec = importlib.util.spec_from_file_location("docker_release_plan", MODULE_PATH)
13 + module = importlib.util.module_from_spec(spec)
14 + assert spec.loader is not None
15 + sys.modules[spec.name] = module
16 + spec.loader.exec_module(module)
17 + return module
18 +
19 +
20 +def git(repo: Path, *args: str) -> str:
21 + result = subprocess.run(
22 + ["git", *args],
23 + cwd=repo,
24 + check=True,
25 + capture_output=True,
26 + text=True,
27 + )
28 + return result.stdout.strip()
29 +
30 +
31 +def commit_file(repo: Path, name: str, content: str, message: str) -> str:
32 + (repo / name).write_text(content, encoding="utf-8")
33 + git(repo, "add", name)
34 + git(repo, "commit", "-m", message)
35 + return git(repo, "rev-parse", "HEAD")
36 +
37 +
38 +def seed_remote_refs(repo: Path, *branches: str) -> None:
39 + for branch in branches:
40 + git(repo, "update-ref", f"refs/remotes/origin/{branch}", git(repo, "rev-parse", branch))
41 +
42 +
43 +def test_docker_publish_workflow_tracks_branch_promotions():
44 + workflow_path = PROJECT_ROOT / ".github" / "workflows" / "docker-publish.yml"
45 + content = workflow_path.read_text(encoding="utf-8")
46 +
47 + assert 'branches:\n - "testing"\n - "main"' in content
48 + assert 'tags:\n - "v*"' in content
49 + assert "workflow_dispatch:" in content
50 + assert "inputs:" in content
51 + assert "tag:" in content
52 + assert 'ref: ${{ matrix.source_tag }}' in content
53 + assert "SOURCE_REF_TYPE: ${{ github.ref_type }}" in content
54 + assert "BEFORE_SHA: ${{ github.event_name == 'push' && github.event.before || '' }}" in content
55 +
56 +
57 +def test_plan_branch_push_builds_when_tag_reaches_allowed_branch(monkeypatch, tmp_path: Path):
58 + release_plan = load_module()
59 +
60 + git(tmp_path, "init", "-b", "main")
61 + git(tmp_path, "config", "user.name", "Test User")
62 + git(tmp_path, "config", "user.email", "test@example.com")
63 +
64 + commit_file(tmp_path, "README.md", "base\n", "base")
65 + git(tmp_path, "tag", "v1.6")
66 + git(tmp_path, "branch", "testing")
67 +
68 + git(tmp_path, "checkout", "-b", "development")
69 + git(tmp_path, "checkout", "main")
70 + git(tmp_path, "merge", "--ff-only", "development")
71 +
72 + git(tmp_path, "checkout", "development")
73 + commit_file(tmp_path, "feature.txt", "release\n", "release v1.7")
74 + git(tmp_path, "tag", "v1.7")
75 +
76 + testing_before = git(tmp_path, "rev-parse", "testing")
77 + git(tmp_path, "checkout", "testing")
78 + git(tmp_path, "merge", "--no-ff", "development", "-m", "promote v1.7 to testing")
79 +
80 + git(tmp_path, "checkout", "main")
81 + git(tmp_path, "merge", "--no-ff", "development", "-m", "promote v1.7 to main")
82 + seed_remote_refs(tmp_path, "testing", "main")
83 +
84 + monkeypatch.chdir(tmp_path)
85 + monkeypatch.setenv("ALLOWED_BRANCHES", "testing main")
86 + monkeypatch.setenv("MAIN_BRANCH", "main")
87 + monkeypatch.setenv("DOCKER_IMAGE_REPO", "example/agent-zero")
88 + monkeypatch.setenv("RELEASE_TAG_REGEX", r"^v([0-9]+)\.([0-9]+)$")
89 + monkeypatch.setenv("MIN_RELEASE_MAJOR", "1")
90 + monkeypatch.setenv("MIN_RELEASE_MINOR", "0")
91 + monkeypatch.setenv("EVENT_NAME", "push")
92 + monkeypatch.setenv("SOURCE_REF_TYPE", "branch")
93 + monkeypatch.setenv("MANUAL_TAG", "")
94 + monkeypatch.setenv("AFTER_SHA", git(tmp_path, "rev-parse", "testing"))
95 +
96 + monkeypatch.setenv("SOURCE_REF_NAME", "testing")
97 + monkeypatch.setenv("BEFORE_SHA", testing_before)
98 + config = release_plan.load_config()
99 + branch_states = release_plan.collect_branch_states(config)
100 + testing_candidates, testing_notes = release_plan.plan_branch_push(config, branch_states)
101 +
102 + assert testing_notes == []
103 + assert len(testing_candidates) == 1
104 + assert testing_candidates[0].branch == "testing"
105 + assert testing_candidates[0].source_tag == "v1.7"
106 + assert testing_candidates[0].mode == "push_promoted_tag"
107 + assert testing_candidates[0].publish_version is False
108 + assert testing_candidates[0].publish_branch_tag is True
109 +
110 + monkeypatch.setenv("SOURCE_REF_NAME", "main")
111 + monkeypatch.setenv("BEFORE_SHA", git(tmp_path, "rev-list", "--max-parents=0", "HEAD"))
112 + monkeypatch.setenv("AFTER_SHA", git(tmp_path, "rev-parse", "main"))
113 + config = release_plan.load_config()
114 + branch_states = release_plan.collect_branch_states(config)
115 + main_candidates, main_notes = release_plan.plan_branch_push(config, branch_states)
116 +
117 + assert main_notes == []
118 + assert len(main_candidates) == 1
119 + assert main_candidates[0].branch == "main"
120 + assert main_candidates[0].source_tag == "v1.7"
121 + assert main_candidates[0].mode == "push_promoted_tag"
122 + assert main_candidates[0].publish_version is True
123 + assert main_candidates[0].publish_branch_tag is True