diff --git a/.github/scripts/docker_release_plan.py b/.github/scripts/docker_release_plan.py index 92e715edb..8e7503958 100644 --- a/.github/scripts/docker_release_plan.py +++ b/.github/scripts/docker_release_plan.py @@ -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}") diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index b819cd46c..500945166 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -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 }} diff --git a/tests/test_docker_release_plan.py b/tests/test_docker_release_plan.py new file mode 100644 index 000000000..c0fa6a589 --- /dev/null +++ b/tests/test_docker_release_plan.py @@ -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