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