You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@airflow.apache.org by ra...@apache.org on 2026/10/08 14:20:20 UTC

(airflow) branch beta-aware-start-rc-process created (now 52d5255e0a3)

This is an automated email from the ASF dual-hosted git repository.

vatsrahul1001 pushed a change to branch beta-aware-start-rc-process
in repository https://gitbox.apache.org/repos/asf/airflow.git


      at 52d5255e0a3 Support cutting beta pre-releases with start-rc-process

This branch includes the following new commits:

     new 52d5255e0a3 Support cutting beta pre-releases with start-rc-process

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



(airflow) 01/01: Support cutting beta pre-releases with start-rc-process

Posted by ra...@apache.org.
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 <ra...@gmail.com>
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
 
 
+@pytest.mark.parametrize(
+    ("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 == []
+
+
+@pytest.mark.parametrize("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"