Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .backportrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@
"backport"
],
"branchLabelMapping": {
"^v(\\d+).(\\d+).\\d+(?:-(?:alpha|beta|rc)\\d+)?$": "$1.$2",
"^v9.6.0$": "main"
"^v9.6.0$": "main",
"^v(\\d+).(\\d+).\\d+(?:-(?:alpha|beta|rc)\\d+)?$": "$1.$2"
},
"copySourcePRLabels": "^(?!backport$)(?!v\\d).*$",
"sourcePRLabels": [
Expand Down
135 changes: 135 additions & 0 deletions dev-tools/unittest/test_update_backportrc.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
#!/usr/bin/env python3
# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
# or more contributor license agreements. Licensed under the Elastic License
# 2.0 and the following additional limitation. Functionality enabled by the
# files subject to the Elastic License 2.0 may only be used in production when
# invoked by an Elasticsearch process with a license key installed that permits
# use of machine learning features. You may not use this file except in
# compliance with the Elastic License 2.0 and the foregoing additional
# limitation.

"""Pytest tests for dev-tools/update_backportrc.py (minor-freeze backportrc update).

The backport tool applies the FIRST matching ``branchLabelMapping`` entry, so the
specific main-only override (``^v<main>$`` -> ``main``) must be emitted before the
generic ``^vX.Y.Z$`` -> ``X.Y`` rule. Otherwise the new main version label resolves
to a non-existent MAJOR.MINOR release branch and the backport fails.
"""

from __future__ import annotations

import sys
from pathlib import Path

import pytest

_DEV_TOOLS = Path(__file__).resolve().parents[1]
if str(_DEV_TOOLS) not in sys.path:
sys.path.insert(0, str(_DEV_TOOLS))

import update_backportrc as ubrc # noqa: E402

_GENERIC_KEY = r"^v(\d+).(\d+).\d+(?:-(?:alpha|beta|rc)\d+)?$"


def _base_data() -> dict:
return {
"targetBranchChoices": ["main", "9.4", "9.3"],
"branchLabelMapping": {
_GENERIC_KEY: "$1.$2",
"^v9.5.0$": "main",
},
}


def test_main_override_emitted_first() -> None:
data = _base_data()
changed = ubrc.update_backportrc_for_minor_freeze(
data, new_release_branch="9.5", main_new_version="9.6.0"
)
assert changed is True

keys = list(data["branchLabelMapping"].keys())
assert keys[0] == "^v9.6.0$", f"main override must be first, got {keys}"
assert data["branchLabelMapping"]["^v9.6.0$"] == "main"
# The generic rule is preserved, just after the override.
assert data["branchLabelMapping"][_GENERIC_KEY] == "$1.$2"


def test_stale_main_override_removed() -> None:
data = _base_data()
ubrc.update_backportrc_for_minor_freeze(
data, new_release_branch="9.5", main_new_version="9.6.0"
)
# The previous main override (^v9.5.0$) must not linger.
assert "^v9.5.0$" not in data["branchLabelMapping"]
main_keys = [k for k, v in data["branchLabelMapping"].items() if v == "main"]
assert main_keys == ["^v9.6.0$"]


def test_reorder_only_is_detected_as_change() -> None:
# Mapping already has the right content but the generic rule is first.
data = {
"targetBranchChoices": ["main", "9.5"],
"branchLabelMapping": {
_GENERIC_KEY: "$1.$2",
"^v9.6.0$": "main",
},
}
changed = ubrc.update_backportrc_for_minor_freeze(
data, new_release_branch="9.5", main_new_version="9.6.0"
)
assert changed is True
assert list(data["branchLabelMapping"].keys())[0] == "^v9.6.0$"


def test_idempotent_when_already_ordered() -> None:
data = {
"targetBranchChoices": ["main", "9.5"],
"branchLabelMapping": {
"^v9.6.0$": "main",
_GENERIC_KEY: "$1.$2",
},
}
changed = ubrc.update_backportrc_for_minor_freeze(
data, new_release_branch="9.5", main_new_version="9.6.0"
)
assert changed is False
assert list(data["branchLabelMapping"].keys())[0] == "^v9.6.0$"


def test_generic_rule_preserved_exactly() -> None:
data = _base_data()
ubrc.update_backportrc_for_minor_freeze(
data, new_release_branch="9.5", main_new_version="9.6.0"
)
assert _GENERIC_KEY in data["branchLabelMapping"]


def test_misconfigured_main_key_is_corrected() -> None:
# Existing mapping already has the new main key but pointing at a (wrong)
# release branch instead of "main". The override must still win.
data = {
"targetBranchChoices": ["main", "9.5"],
"branchLabelMapping": {
_GENERIC_KEY: "$1.$2",
"^v9.6.0$": "9.6",
},
}
changed = ubrc.update_backportrc_for_minor_freeze(
data, new_release_branch="9.5", main_new_version="9.6.0"
)
assert changed is True
keys = list(data["branchLabelMapping"].keys())
assert keys[0] == "^v9.6.0$", f"main override must be first, got {keys}"
assert data["branchLabelMapping"]["^v9.6.0$"] == "main"


@pytest.mark.parametrize("bad_version", ["9.6", "v9.6.0", "9", "9.6.0.1", "9.6.x", ""])
def test_rejects_non_semver_main_new_version(bad_version: str) -> None:
# A value like "9.6" would produce key ^v9.6$ and silently never match a
# v9.6.0 label, so the main override must be rejected up front.
with pytest.raises(ValueError, match="MAJOR.MINOR.PATCH"):
ubrc.update_backportrc_for_minor_freeze(
_base_data(), new_release_branch="9.5", main_new_version=bad_version
)
52 changes: 38 additions & 14 deletions dev-tools/update_backportrc.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,32 @@

import argparse
import json
import re
import sys
from pathlib import Path
from typing import Any

_MAIN_NEW_VERSION_RE = re.compile(r"\d+\.\d+\.\d+")


def update_backportrc_for_minor_freeze(
data: dict[str, Any],
*,
new_release_branch: str,
main_new_version: str,
) -> bool:
"""Apply minor-freeze updates in place. Returns True if anything changed."""
"""Apply minor-freeze updates in place. Returns True if anything changed.

Raises ValueError if main_new_version is not MAJOR.MINOR.PATCH: a value like
"9.6" would produce the key ^v9.6$, which never matches a v9.6.0 label, so the
main override would silently never fire.
"""
if not _MAIN_NEW_VERSION_RE.fullmatch(main_new_version):
raise ValueError(
"main_new_version must be MAJOR.MINOR.PATCH (e.g. 9.6.0), "
f"got {main_new_version!r}"
)

changed = False

choices: list[str] = list(data.get("targetBranchChoices", []))
Expand All @@ -38,16 +52,22 @@ def update_backportrc_for_minor_freeze(
data["targetBranchChoices"] = choices
changed = True

mapping: dict[str, str] = dict(data.get("branchLabelMapping", {}))
old_mapping: dict[str, str] = dict(data.get("branchLabelMapping", {}))
new_main_key = f"^v{main_new_version}$"
old_main_keys = [k for k, v in mapping.items() if v == "main" and k != new_main_key]
for key in old_main_keys:
del mapping[key]
changed = True
if mapping.get(new_main_key) != "main":
mapping[new_main_key] = "main"
# Rebuild the mapping with the main-only override FIRST. The backport tool applies
# the first matching branchLabelMapping key, so the specific main override (e.g.
# ^v9.6.0$ -> main) must precede the generic ^vX.Y.Z$ -> X.Y rule. Otherwise the
# main version label resolves to a non-existent MAJOR.MINOR release branch and the
# backport fails (see PR #3071 attempting a non-existent "9.6" branch).
# Exclude new_main_key so a misconfigured existing entry (e.g. ^v9.6.0$ -> "9.6")
# cannot overwrite the correct override below via the update() call.
non_main = {k: v for k, v in old_mapping.items() if v != "main" and k != new_main_key}
new_mapping: dict[str, str] = {new_main_key: "main"}
new_mapping.update(non_main)
# dict equality ignores order, so compare item order explicitly to catch reorders.
if list(new_mapping.items()) != list(old_mapping.items()):
changed = True
data["branchLabelMapping"] = mapping
data["branchLabelMapping"] = new_mapping

return changed

Expand All @@ -61,11 +81,15 @@ def _cmd_update(args: argparse.Namespace) -> int:
with path.open(encoding="utf-8") as handle:
data = json.load(handle)

changed = update_backportrc_for_minor_freeze(
data,
new_release_branch=args.new_release_branch,
main_new_version=args.main_new_version,
)
try:
changed = update_backportrc_for_minor_freeze(
data,
new_release_branch=args.new_release_branch,
main_new_version=args.main_new_version,
)
except ValueError as e:
print(f"ERROR: {e}", file=sys.stderr)
return 1
if not changed:
print(f"OK: {path} already configured for branch {args.new_release_branch} and main {args.main_new_version}")
return 0
Expand Down