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.
This commit is contained in:
frdel 2026-03-26 08:22:18 +01:00
parent 2c619b4b0e
commit ce295c95db
3 changed files with 220 additions and 18 deletions

View file

@ -69,8 +69,11 @@ class Config:
tag_pattern: re.Pattern[str]
min_version: tuple[int, int]
event_name: str
source_tag: str
source_ref_name: str
source_ref_type: str
manual_tag: str
before_sha: str
after_sha: str
@dataclass(frozen=True)
@ -109,8 +112,11 @@ def load_config() -> Config:
int(os.environ["MIN_RELEASE_MINOR"]),
),
event_name=os.environ["EVENT_NAME"].strip(),
source_tag=os.environ.get("SOURCE_TAG", "").strip(),
source_ref_name=os.environ.get("SOURCE_REF_NAME", "").strip(),
source_ref_type=os.environ.get("SOURCE_REF_TYPE", "").strip(),
manual_tag=os.environ.get("MANUAL_TAG", "").strip(),
before_sha=os.environ.get("BEFORE_SHA", "").strip(),
after_sha=os.environ.get("AFTER_SHA", "").strip(),
)
@ -146,22 +152,40 @@ def branch_contains_commit(branch: str, commit: str) -> bool:
)
def ref_exists(ref: str) -> bool:
if not ref or re.fullmatch(r"0{40}", ref):
return False
return run_command("git", "rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}", check=False).returncode == 0
def releasable_tags_for_ref(config: Config, ref: str) -> list[str]:
if not ref_exists(ref):
return []
tagged_versions: list[tuple[tuple[int, int], str]] = []
merged_tags = git("tag", "--merged", ref)
for tag in merged_tags.splitlines():
version = parse_release_tag(config, tag.strip())
if version is None:
continue
tagged_versions.append((version, tag.strip()))
tagged_versions.sort(key=lambda item: item[0])
return [tag for _, tag in tagged_versions]
def latest_releasable_tag_for_ref(config: Config, ref: str) -> str | None:
valid_tags = releasable_tags_for_ref(config, ref)
return valid_tags[-1] if valid_tags else None
def collect_branch_states(config: Config, branches: list[str] | None = None) -> dict[str, BranchState]:
states: dict[str, BranchState] = {}
for branch in branches or config.allowed_branches:
if run_command("git", "show-ref", "--verify", "--quiet", f"refs/remotes/origin/{branch}", check=False).returncode != 0:
fail(f"Allowed branch origin/{branch} was not fetched.")
tagged_versions: list[tuple[tuple[int, int], str]] = []
merged_tags = git("tag", "--merged", f"origin/{branch}")
for tag in merged_tags.splitlines():
version = parse_release_tag(config, tag.strip())
if version is None:
continue
tagged_versions.append((version, tag.strip()))
tagged_versions.sort(key=lambda item: item[0])
valid_tags = [tag for _, tag in tagged_versions]
valid_tags = releasable_tags_for_ref(config, f"origin/{branch}")
states[branch] = BranchState(
branch=branch,
valid_tags=valid_tags,
@ -182,8 +206,8 @@ def add_or_merge_candidate(candidates: dict[tuple[str, str, str], Candidate], ca
existing.reason = f"{existing.reason}; {candidate.reason}"
def plan_push(config: Config, branch_states: dict[str, BranchState]) -> tuple[list[Candidate], list[str]]:
source_tag = config.source_tag
def plan_tag_push(config: Config, branch_states: dict[str, BranchState]) -> tuple[list[Candidate], list[str]]:
source_tag = config.source_ref_name
notes: list[str] = []
version = parse_release_tag(config, source_tag)
if version is None:
@ -219,6 +243,30 @@ def plan_push(config: Config, branch_states: dict[str, BranchState]) -> tuple[li
return candidates, notes
def plan_branch_push(config: Config, branch_states: dict[str, BranchState]) -> tuple[list[Candidate], list[str]]:
branch = config.source_ref_name
if branch not in branch_states:
return [], [f"Skipped `{branch}` because it is not an allowed release branch."]
before_tag = latest_releasable_tag_for_ref(config, config.before_sha)
after_tag = branch_states[branch].latest_tag
if after_tag is None:
return [], [f"Skipped `{branch}` because it has no releasable tags."]
if before_tag == after_tag:
return [], [f"Skipped `{branch}` because its highest release tag is still `{after_tag}`."]
return [
Candidate(
branch=branch,
source_tag=after_tag,
mode="push_promoted_tag",
publish_version=branch == config.main_branch,
publish_branch_tag=True,
reason=f"Automatic build for `{after_tag}` after it reached `{branch}`.",
)
], []
def plan_manual_exact(config: Config, branch_states: dict[str, BranchState]) -> tuple[list[Candidate], list[str]]:
manual_tag = config.manual_tag
if parse_release_tag(config, manual_tag) is None:
@ -335,7 +383,12 @@ def plan_command() -> None:
else:
candidates, notes = plan_manual_backfill(config, branch_states)
elif config.event_name == "push":
candidates, notes = plan_push(config, branch_states)
if config.source_ref_type == "tag":
candidates, notes = plan_tag_push(config, branch_states)
elif config.source_ref_type == "branch":
candidates, notes = plan_branch_push(config, branch_states)
else:
fail(f"Unsupported push ref type: {config.source_ref_type}")
else:
fail(f"Unsupported event: {config.event_name}")
@ -457,6 +510,19 @@ def resolve_build_command() -> None:
if publish_branch_tag:
tags_to_push.append(f"{config.image_repo}:{mutable_tag}")
elif mode == "push_promoted_tag":
if branch_state.latest_tag != source_tag:
write_output("should_build", "false")
write_output(
"skip_reason",
f"Tag `{source_tag}` is no longer the highest release tag on `{branch}`.",
)
return
if publish_version and not docker_tag_exists(config.image_repo, source_tag):
tags_to_push.append(f"{config.image_repo}:{source_tag}")
if publish_branch_tag:
tags_to_push.append(f"{config.image_repo}:{mutable_tag}")
elif mode == "manual_exact":
if publish_version:
tags_to_push.append(f"{config.image_repo}:{source_tag}")

View file

@ -2,6 +2,9 @@ name: Build And Publish Docker Images
on:
push:
branches:
- "testing"
- "main"
tags:
- "v*"
workflow_dispatch:
@ -66,7 +69,10 @@ jobs:
id: plan
env:
EVENT_NAME: ${{ github.event_name }}
SOURCE_TAG: ${{ github.ref_name }}
SOURCE_REF_NAME: ${{ github.ref_name }}
SOURCE_REF_TYPE: ${{ github.ref_type }}
BEFORE_SHA: ${{ github.event_name == 'push' && github.event.before || '' }}
AFTER_SHA: ${{ github.event_name == 'push' && github.sha || '' }}
MANUAL_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || '' }}
DOCKER_IMAGE_REPO: ${{ format('{0}/{1}', secrets.DOCKERHUB_ORG, env.DOCKER_IMAGE_NAME) }}
run: python3 .github/scripts/docker_release_plan.py plan
@ -88,6 +94,7 @@ jobs:
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ matrix.source_tag }}
- name: Fetch remote branches and tags
run: git fetch --force --tags origin '+refs/heads/*:refs/remotes/origin/*'
@ -118,7 +125,10 @@ jobs:
id: resolve
env:
EVENT_NAME: ${{ github.event_name }}
SOURCE_TAG: ${{ github.ref_name }}
SOURCE_REF_NAME: ${{ github.ref_name }}
SOURCE_REF_TYPE: ${{ github.ref_type }}
BEFORE_SHA: ${{ github.event_name == 'push' && github.event.before || '' }}
AFTER_SHA: ${{ github.event_name == 'push' && github.sha || '' }}
MANUAL_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || '' }}
DOCKER_IMAGE_REPO: ${{ format('{0}/{1}', secrets.DOCKERHUB_ORG, env.DOCKER_IMAGE_NAME) }}
TARGET_BRANCH: ${{ matrix.branch }}
@ -155,7 +165,10 @@ jobs:
id: release_plan
env:
EVENT_NAME: ${{ github.event_name }}
SOURCE_TAG: ${{ github.ref_name }}
SOURCE_REF_NAME: ${{ github.ref_name }}
SOURCE_REF_TYPE: ${{ github.ref_type }}
BEFORE_SHA: ${{ github.event_name == 'push' && github.event.before || '' }}
AFTER_SHA: ${{ github.event_name == 'push' && github.sha || '' }}
MANUAL_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || '' }}
DOCKER_IMAGE_REPO: ${{ format('{0}/{1}', secrets.DOCKERHUB_ORG, env.DOCKER_IMAGE_NAME) }}
TARGET_BRANCH: ${{ matrix.branch }}

View file

@ -0,0 +1,123 @@
import importlib.util
import subprocess
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[1]
MODULE_PATH = PROJECT_ROOT / ".github" / "scripts" / "docker_release_plan.py"
def load_module():
spec = importlib.util.spec_from_file_location("docker_release_plan", MODULE_PATH)
module = importlib.util.module_from_spec(spec)
assert spec.loader is not None
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
def git(repo: Path, *args: str) -> str:
result = subprocess.run(
["git", *args],
cwd=repo,
check=True,
capture_output=True,
text=True,
)
return result.stdout.strip()
def commit_file(repo: Path, name: str, content: str, message: str) -> str:
(repo / name).write_text(content, encoding="utf-8")
git(repo, "add", name)
git(repo, "commit", "-m", message)
return git(repo, "rev-parse", "HEAD")
def seed_remote_refs(repo: Path, *branches: str) -> None:
for branch in branches:
git(repo, "update-ref", f"refs/remotes/origin/{branch}", git(repo, "rev-parse", branch))
def test_docker_publish_workflow_tracks_branch_promotions():
workflow_path = PROJECT_ROOT / ".github" / "workflows" / "docker-publish.yml"
content = workflow_path.read_text(encoding="utf-8")
assert 'branches:\n - "testing"\n - "main"' in content
assert 'tags:\n - "v*"' in content
assert "workflow_dispatch:" in content
assert "inputs:" in content
assert "tag:" in content
assert 'ref: ${{ matrix.source_tag }}' in content
assert "SOURCE_REF_TYPE: ${{ github.ref_type }}" in content
assert "BEFORE_SHA: ${{ github.event_name == 'push' && github.event.before || '' }}" in content
def test_plan_branch_push_builds_when_tag_reaches_allowed_branch(monkeypatch, tmp_path: Path):
release_plan = load_module()
git(tmp_path, "init", "-b", "main")
git(tmp_path, "config", "user.name", "Test User")
git(tmp_path, "config", "user.email", "test@example.com")
commit_file(tmp_path, "README.md", "base\n", "base")
git(tmp_path, "tag", "v1.6")
git(tmp_path, "branch", "testing")
git(tmp_path, "checkout", "-b", "development")
git(tmp_path, "checkout", "main")
git(tmp_path, "merge", "--ff-only", "development")
git(tmp_path, "checkout", "development")
commit_file(tmp_path, "feature.txt", "release\n", "release v1.7")
git(tmp_path, "tag", "v1.7")
testing_before = git(tmp_path, "rev-parse", "testing")
git(tmp_path, "checkout", "testing")
git(tmp_path, "merge", "--no-ff", "development", "-m", "promote v1.7 to testing")
git(tmp_path, "checkout", "main")
git(tmp_path, "merge", "--no-ff", "development", "-m", "promote v1.7 to main")
seed_remote_refs(tmp_path, "testing", "main")
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("ALLOWED_BRANCHES", "testing main")
monkeypatch.setenv("MAIN_BRANCH", "main")
monkeypatch.setenv("DOCKER_IMAGE_REPO", "example/agent-zero")
monkeypatch.setenv("RELEASE_TAG_REGEX", r"^v([0-9]+)\.([0-9]+)$")
monkeypatch.setenv("MIN_RELEASE_MAJOR", "1")
monkeypatch.setenv("MIN_RELEASE_MINOR", "0")
monkeypatch.setenv("EVENT_NAME", "push")
monkeypatch.setenv("SOURCE_REF_TYPE", "branch")
monkeypatch.setenv("MANUAL_TAG", "")
monkeypatch.setenv("AFTER_SHA", git(tmp_path, "rev-parse", "testing"))
monkeypatch.setenv("SOURCE_REF_NAME", "testing")
monkeypatch.setenv("BEFORE_SHA", testing_before)
config = release_plan.load_config()
branch_states = release_plan.collect_branch_states(config)
testing_candidates, testing_notes = release_plan.plan_branch_push(config, branch_states)
assert testing_notes == []
assert len(testing_candidates) == 1
assert testing_candidates[0].branch == "testing"
assert testing_candidates[0].source_tag == "v1.7"
assert testing_candidates[0].mode == "push_promoted_tag"
assert testing_candidates[0].publish_version is False
assert testing_candidates[0].publish_branch_tag is True
monkeypatch.setenv("SOURCE_REF_NAME", "main")
monkeypatch.setenv("BEFORE_SHA", git(tmp_path, "rev-list", "--max-parents=0", "HEAD"))
monkeypatch.setenv("AFTER_SHA", git(tmp_path, "rev-parse", "main"))
config = release_plan.load_config()
branch_states = release_plan.collect_branch_states(config)
main_candidates, main_notes = release_plan.plan_branch_push(config, branch_states)
assert main_notes == []
assert len(main_candidates) == 1
assert main_candidates[0].branch == "main"
assert main_candidates[0].source_tag == "v1.7"
assert main_candidates[0].mode == "push_promoted_tag"
assert main_candidates[0].publish_version is True
assert main_candidates[0].publish_branch_tag is True