This is an automated email from the ASF dual-hosted git repository. vatsrahul1001 pushed a commit to branch beta-aware-start-rc-process in repository https://gitbox.apache.org/repos/asf/airflow.git
commit 52d5255e0a3c5f9975f6ae17c0e1327c262a7acc Author: Rahul Vats <[email protected]> AuthorDate: Thu Oct 8 19:49:07 2026 +0530 Support cutting beta pre-releases with start-rc-process Betas and alphas are cut before a vX-Y-stable branch exists, but the command required it for validation, tagging and constraints, so betas had to be cut by hand. Route pre-releases that are not rc to vX-Y-test instead of vX-Y-stable. For their constraints, tag the shared constraints-X-Y branch tip (a beta pins providers at their released versions, which that tip already holds) rather than re-running the identical release-constraints workflow, which is kept for rc and final. --- dev/README_RELEASE_AIRFLOW.md | 20 ++++ .../commands/release_candidate_command.py | 123 ++++++++++++++------- dev/breeze/tests/test_release_candidate_command.py | 76 +++++++++++++ 3 files changed, 180 insertions(+), 39 deletions(-) diff --git a/dev/README_RELEASE_AIRFLOW.md b/dev/README_RELEASE_AIRFLOW.md index d29de1bbe39..d76acdf9a7e 100644 --- a/dev/README_RELEASE_AIRFLOW.md +++ b/dev/README_RELEASE_AIRFLOW.md @@ -114,6 +114,26 @@ moves `vX-Y-test` forward to the current `main` - only the branch-specific commi `Update default branches for X.Y`) are kept on top of `main`. Each beta is cut from the branch in that state, so everything merged to `main` lands in the next beta. +A beta (`X.Y.0bN`) is cut with the same [`start-rc-process`](#build-rc-artifacts) command as a +release candidate - the only difference is that there is no vote. Because no `vX-Y-stable` branch +exists yet during the beta phase, `start-rc-process` detects a non-`rc` pre-release from `--version` +and automatically validates, merges and tags against `vX-Y-test` instead of `vX-Y-stable` (for an +`rc` it uses `vX-Y-stable` as before). No stable branch is required to cut a beta; the stable branch +is created at `X.Y.0rc1`. + +Constraints are handled differently for a beta. A beta pins providers at their released versions +from sources that match `main`, so its resolution is already what the shared `constraints-X-Y` +branch holds - `start-rc-process` therefore tags `constraints-X.Y.0bN` at the `constraints-X-Y` +branch tip rather than triggering the `release-constraints` workflow (which only earns its cost for +an `rc`, where the provider wave on PyPI must be pinned, and for a final, which commits onto +`constraints-X-Y`). Sync `constraints-X-Y` to `constraints-main` before cutting the beta so the tip +is current. + +When `start-rc-process` prompts to merge the sync PR for a beta, answer **yes** to merge (so the +beta's version/release-notes commit is created locally and tagged) but **no** to the push - the +commit must not land on `vX-Y-test`, or it would diverge from `main` and break the next +fast-forward. The beta tag therefore references that commit without it being on `vX-Y-test`. + During this phase, all automation that would add commits directly to `vX-Y-test` must be paused - any such commit makes `vX-Y-test` diverge from `main` and breaks the next fast-forward. Upgrades and fixes land on `main` only and reach `vX-Y-test` with the next fast-forward. When you add the new diff --git a/dev/breeze/src/airflow_breeze/commands/release_candidate_command.py b/dev/breeze/src/airflow_breeze/commands/release_candidate_command.py index f6ac278bb35..45f6dc99335 100644 --- a/dev/breeze/src/airflow_breeze/commands/release_candidate_command.py +++ b/dev/breeze/src/airflow_breeze/commands/release_candidate_command.py @@ -119,13 +119,25 @@ def validate_git_status(): console_print("[success]Working directory is clean") -def validate_version_branches_exist(version_branch, remote_name): +def get_candidate_base_branch(version: str, version_branch: str) -> str: + """Return the branch a release candidate is cut, tagged and constrained from. + + ``rc`` candidates are cut from ``vX-Y-stable``. ``alpha``/``beta`` pre-releases are cut + earlier in the cycle, before a stable branch exists, so they are cut from ``vX-Y-test``. + """ + from packaging.version import Version + + pre = Version(version).pre + if pre and pre[0] == "rc": + return f"v{version_branch}-stable" + return f"v{version_branch}-test" + + +def validate_version_branches_exist(version_branch, remote_name, candidate_base_branch): """Validate that the required version branches exist.""" console_print(f"[info]Validating version branches exist for {version_branch}...") - # Check if test branch exists test_branch = f"v{version_branch}-test" - stable_branch = f"v{version_branch}-stable" # Fetch to get latest remote branches run_command(["git", "fetch", remote_name], check=True) @@ -139,22 +151,16 @@ def validate_version_branches_exist(version_branch, remote_name): ) remote_branches = result.stdout - test_branch_exists = f"{remote_name}/{test_branch}" in remote_branches - stable_branch_exists = f"{remote_name}/{stable_branch}" in remote_branches - - if not test_branch_exists: - console_print(f"[error]Test branch '{remote_name}/{test_branch}' does not exist!") - console_print("Available remote branches:") - run_command(["git", "branch", "-r"]) - exit(1) - console_print(f"[success]Test branch '{remote_name}/{test_branch}' exists") - - if not stable_branch_exists: - console_print(f"[error]Stable branch '{remote_name}/{stable_branch}' does not exist!") - console_print("Available remote branches:") - run_command(["git", "branch", "-r"]) - exit(1) - console_print(f"[success]Stable branch '{remote_name}/{stable_branch}' exists") + # The test branch is always required (the sync branch is based on it); the candidate is cut, + # tagged and constrained from candidate_base_branch (stable for rc, test for alpha/beta). + required_branches = {test_branch: "Test branch", candidate_base_branch: "Candidate base branch"} + for branch, role in required_branches.items(): + if f"{remote_name}/{branch}" not in remote_branches: + console_print(f"[error]{role} '{remote_name}/{branch}' does not exist!") + console_print("Available remote branches:") + run_command(["git", "branch", "-r"]) + exit(1) + console_print(f"[success]{role} '{remote_name}/{branch}' exists") def validate_tag_does_not_exist(version, remote_name): @@ -199,12 +205,10 @@ def validate_tag_does_not_exist(version, remote_name): exit(1) -def validate_on_correct_branch_for_tagging(version_branch): - """Validate that we're on the correct branch for tagging (stable branch).""" +def validate_on_correct_branch_for_tagging(candidate_base_branch): + """Validate that we're on the correct branch for tagging (the candidate base branch).""" console_print("[info]Validating we're on the correct branch for tagging...") - expected_branch = f"v{version_branch}-stable" - # Check current branch result = run_command( ["git", "branch", "--show-current"], @@ -214,26 +218,26 @@ def validate_on_correct_branch_for_tagging(version_branch): ) current_branch = result.stdout.strip() - if current_branch != expected_branch: - console_print(f"[error]Currently on branch '{current_branch}', expected '{expected_branch}'!") - console_print("Tags should be created on the stable branch after merging the sync PR.") + if current_branch != candidate_base_branch: + console_print(f"[error]Currently on branch '{current_branch}', expected '{candidate_base_branch}'!") + console_print("Tags should be created on the candidate base branch after merging the sync PR.") console_print("Make sure the PR merge step completed successfully.") exit(1) - console_print(f"[success]On correct branch '{expected_branch}' for tagging") + console_print(f"[success]On correct branch '{candidate_base_branch}' for tagging") -def merge_pr(version_branch, remote_name, sync_branch): +def merge_pr(remote_name, sync_branch, candidate_base_branch): if confirm_action("Do you want to merge the Sync PR?"): run_command( [ "git", "checkout", - f"v{version_branch}-stable", + candidate_base_branch, ], check=True, ) run_command( - ["git", "reset", "--hard", f"{remote_name}/v{version_branch}-stable"], + ["git", "reset", "--hard", f"{remote_name}/{candidate_base_branch}"], check=True, ) run_command( @@ -242,7 +246,7 @@ def merge_pr(version_branch, remote_name, sync_branch): ) if confirm_action("Do you want to push the changes? Pushing the changes closes the PR"): run_command( - ["git", "push", remote_name, f"v{version_branch}-stable"], + ["git", "push", remote_name, candidate_base_branch], check=True, ) @@ -443,10 +447,48 @@ def sign_the_release(repo_root): console_print("[success]Release signed") -def generate_and_push_constraints(version, version_branch): - # Resolved from the stable branch the candidate was cut from, so the constraints describe the - # sources being voted on. The workflow reads "rcN" and allows pre-releases accordingly. - publish_constraints(version=version, ref=f"v{version_branch}-stable") +def tag_constraints_from_branch_tip(version, version_branch, remote_name): + """Tag ``constraints-<version>`` at the tip of the shared ``constraints-X-Y`` branch. + + An alpha/beta pins providers at their released versions from sources that match ``main``, so its + resolution is already what the shared ``constraints-X-Y`` branch holds. Tagging that tip (as was + done before the release-constraints workflow existed) avoids re-running the identical resolution + on CI runners. Sync ``constraints-X-Y`` to ``constraints-main`` before cutting the beta so the + tip is current. + """ + constraints_branch = f"constraints-{version_branch}" + constraints_tag = f"constraints-{version}" + if not confirm_action(f"Tag {constraints_tag} at the tip of {remote_name}/{constraints_branch}?"): + return + run_command(["git", "fetch", remote_name, constraints_branch], check=True) + run_command( + [ + "git", + "tag", + "-a", + constraints_tag, + f"{remote_name}/{constraints_branch}", + "-m", + f"Constraints for Apache Airflow {version}", + ], + check=True, + ) + run_command(["git", "push", remote_name, f"refs/tags/{constraints_tag}"], check=True) + console_print(f"[success]Tagged {constraints_tag} at {remote_name}/{constraints_branch} tip") + + +def generate_and_push_constraints(version, version_branch, candidate_base_branch, remote_name): + from packaging.version import Version + + pre = Version(version).pre + if pre and pre[0] == "rc": + # Resolved from the stable branch the candidate was cut from, so the constraints describe the + # sources being voted on. The workflow reads "rcN" and allows pre-releases accordingly. + publish_constraints(version=version, ref=candidate_base_branch) + else: + # alpha/beta: the resolution already matches the shared constraints-X-Y branch tip, so tag + # it instead of re-running the (identical) resolution workflow. + tag_constraints_from_branch_tip(version, version_branch, remote_name) def clone_asf_repo(version, repo_root): @@ -767,6 +809,8 @@ def publish_release_candidate( version_suffix = airflow_version.pre[0] + str(airflow_version.pre[1]) version_branch = str(airflow_version.release[0]) + "-" + str(airflow_version.release[1]) version_without_rc = airflow_version.base_version + # rc is cut from vX-Y-stable; alpha/beta from vX-Y-test (no stable branch exists yet). + candidate_base_branch = get_candidate_base_branch(version, version_branch) task_sdk_version_obj = Version(task_sdk_version) task_sdk_version_without_rc = task_sdk_version_obj.base_version @@ -778,7 +822,7 @@ def publish_release_candidate( console_print("[info]Skipping validations in dry-run mode") validate_remote_tracks_apache_airflow(remote_name) validate_git_status() - validate_version_branches_exist(version_branch, remote_name) + validate_version_branches_exist(version_branch, remote_name, candidate_base_branch) validate_tag_does_not_exist(version, remote_name) validate_tag_does_not_exist(f"task-sdk/{task_sdk_version}", remote_name) @@ -789,6 +833,7 @@ def publish_release_candidate( console_print(f"Task SDK version: {task_sdk_version}") console_print(f"version_suffix: {version_suffix}") console_print(f"version_branch: {version_branch}") + console_print(f"candidate_base_branch: {candidate_base_branch}") console_print(f"version_without_rc: {version_without_rc}") console_print(f"task_sdk_version_without_rc: {task_sdk_version_without_rc}") console_print(f"airflow_repo_root: {airflow_repo_root}") @@ -800,12 +845,12 @@ def publish_release_candidate( console_print() confirm_action("Verify that the above information is correct. Do you want to continue?", abort=True) # Merge the sync PR - merge_pr(version_branch, remote_name, sync_branch) + merge_pr(remote_name, sync_branch, candidate_base_branch) # # # Tag & clean the repo # Validate we're on the correct branch before tagging if not get_dry_run(): - validate_on_correct_branch_for_tagging(version_branch) + validate_on_correct_branch_for_tagging(candidate_base_branch) git_tag(version, f"Apache Airflow {version}") git_tag(f"task-sdk/{task_sdk_version}", f"Airflow Task SDK {task_sdk_version}") git_clean() @@ -829,7 +874,7 @@ def publish_release_candidate( # Sign the release sign_the_release(airflow_repo_root) # Generate, publish and tag the constraints for this candidate - generate_and_push_constraints(version, version_branch) + generate_and_push_constraints(version, version_branch, candidate_base_branch, remote_name) # Clone the asf repo clone_asf_repo(version, airflow_repo_root) # Move artifacts to SVN diff --git a/dev/breeze/tests/test_release_candidate_command.py b/dev/breeze/tests/test_release_candidate_command.py index 8f50ab526bb..dbc4d7314d9 100644 --- a/dev/breeze/tests/test_release_candidate_command.py +++ b/dev/breeze/tests/test_release_candidate_command.py @@ -45,6 +45,82 @@ def rc_cmd(): return module [email protected]( + ("version", "version_branch", "expected"), + [ + pytest.param("3.4.0rc1", "3-4", "v3-4-stable", id="rc-uses-stable"), + pytest.param("3.4.0rc2", "3-4", "v3-4-stable", id="later-rc-uses-stable"), + pytest.param("3.4.0b1", "3-4", "v3-4-test", id="beta-uses-test"), + pytest.param("3.4.0a1", "3-4", "v3-4-test", id="alpha-uses-test"), + ], +) +def test_get_candidate_base_branch(rc_cmd, version, version_branch, expected): + """rc candidates are cut from the stable branch; alpha/beta from the test branch.""" + assert rc_cmd.get_candidate_base_branch(version, version_branch) == expected + + +def test_generate_and_push_constraints_rc_triggers_workflow(monkeypatch, rc_cmd): + """An rc resolves fresh constraints via the release-constraints workflow, from the stable branch.""" + publish_calls: list[dict] = [] + tag_tip_calls: list[tuple] = [] + + monkeypatch.setattr(rc_cmd, "publish_constraints", lambda **kwargs: publish_calls.append(kwargs)) + monkeypatch.setattr( + rc_cmd, + "tag_constraints_from_branch_tip", + lambda *args: tag_tip_calls.append(args), + ) + + rc_cmd.generate_and_push_constraints("3.4.0rc1", "3-4", "v3-4-stable", "origin") + + assert publish_calls == [{"version": "3.4.0rc1", "ref": "v3-4-stable"}] + assert tag_tip_calls == [] + + [email protected]("version", ["3.4.0b1", "3.4.0a1"]) +def test_generate_and_push_constraints_beta_tags_branch_tip(monkeypatch, rc_cmd, version): + """An alpha/beta tags the shared constraints branch tip instead of running the workflow.""" + publish_calls: list[dict] = [] + tag_tip_calls: list[tuple] = [] + + monkeypatch.setattr(rc_cmd, "publish_constraints", lambda **kwargs: publish_calls.append(kwargs)) + monkeypatch.setattr( + rc_cmd, + "tag_constraints_from_branch_tip", + lambda *args: tag_tip_calls.append(args), + ) + + rc_cmd.generate_and_push_constraints(version, "3-4", "v3-4-test", "origin") + + assert publish_calls == [] + assert tag_tip_calls == [(version, "3-4", "origin")] + + +def test_tag_constraints_from_branch_tip_fetches_tags_and_pushes(monkeypatch, rc_cmd): + """Tags constraints-<version> at the remote constraints-X-Y tip and pushes the tag.""" + run_command_calls: list[list[str]] = [] + + monkeypatch.setattr(rc_cmd, "confirm_action", lambda *a, **k: True) + monkeypatch.setattr(rc_cmd, "console_print", lambda *a, **k: None) + monkeypatch.setattr(rc_cmd, "run_command", lambda cmd, **k: run_command_calls.append(cmd)) + + rc_cmd.tag_constraints_from_branch_tip("3.4.0b1", "3-4", "origin") + + assert run_command_calls == [ + ["git", "fetch", "origin", "constraints-3-4"], + [ + "git", + "tag", + "-a", + "constraints-3.4.0b1", + "origin/constraints-3-4", + "-m", + "Constraints for Apache Airflow 3.4.0b1", + ], + ["git", "push", "origin", "refs/tags/constraints-3.4.0b1"], + ] + + def test_clone_asf_repo(monkeypatch, rc_cmd): """Test that clone_asf_repo behaves correctly based on CI environment.""" version = "2.10.0rc3"
