You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tooling.apache.org by ar...@apache.org on 2026/07/30 10:50:27 UTC

(tooling-trusted-releases) branch arm updated (0db15d2a -> d49814eb)

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

alitheg pushed a change to branch arm
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git


 discard 0db15d2a #1449  - make sure all fields can be set from .asf.yaml
     new d49814eb #1449  - make sure all fields can be set from .asf.yaml

This update added new revisions after undoing existing revisions.
That is to say, some revisions that were in the old version of the
branch are not in the new version.  This situation occurs
when a user --force pushes a change and generates a repository
containing something like this:

 * -- * -- B -- O -- O -- O   (0db15d2a)
            \
             N -- N -- N   refs/heads/arm (d49814eb)

You should already have received notification emails for all of the O
revisions, and so the following emails describe only the N revisions
from the common base, B.

Any revisions marked "omit" are not gone; other references still
refer to them.  Any revisions marked "discard" are gone forever.

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.


Summary of changes:
 atr/models/api.py | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org


(tooling-trusted-releases) 01/01: #1449 - make sure all fields can be set from .asf.yaml

Posted by ar...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

alitheg pushed a commit to branch arm
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git

commit d49814eb7c57037e778eab5e7676a95df966f09a
Author: Alastair McFarlane <ar...@apache.org>
AuthorDate: Thu Jul 30 11:23:12 2026 +0100

    #1449  - make sure all fields can be set from .asf.yaml
---
 atr/calver.py                          | 206 ---------------------------------
 atr/cycles.py                          |   2 +-
 atr/get/projects.py                    |  19 +++
 atr/models/__init__.py                 |   2 +
 atr/models/api.py                      |  19 ++-
 atr/shared/projects.py                 |   2 +-
 atr/storage/writers/policy.py          |   5 +-
 atr/storage/writers/project.py         |  35 +++++-
 tests/unit/test_asf_yaml_export.py     |  31 +++++
 tests/unit/test_calver.py              |   2 +-
 tests/unit/test_cycles.py              |   2 +-
 tests/unit/test_project_config_args.py |  60 ++++++++++
 12 files changed, 166 insertions(+), 219 deletions(-)

diff --git a/atr/calver.py b/atr/calver.py
deleted file mode 100644
index b23e8823..00000000
--- a/atr/calver.py
+++ /dev/null
@@ -1,206 +0,0 @@
-# Licensed to the Apache Software Foundation (ASF) under one
-# or more contributor license agreements.  See the NOTICE file
-# distributed with this work for additional information
-# regarding copyright ownership.  The ASF licenses this file
-# to you under the Apache License, Version 2.0 (the
-# "License"); you may not use this file except in compliance
-# with the License.  You may obtain a copy of the License at
-#
-#   http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing,
-# software distributed under the License is distributed on an
-# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-# KIND, either express or implied.  See the License for the
-# specific language governing permissions and limitations
-# under the License.
-
-"""Date-format compiler for calendar-versioned projects.
-
-A calver project declares the shape of its version strings as a date format
-rather than a raw regex. The format is a sequence of fixed tokens with literal
-separators between them:
-
-    YYYY / YY   four- or two-digit year
-    MM / M      zero-padded or 1-2 digit month
-    DD / D      zero-padded or 1-2 digit day
-    N           a numeric serial (a patch ordinal within a calendar line)
-
-A single parenthesised span marks which part forms the cycle key. Trailing
-tokens are optional, so one format covers mixed-granularity histories: for
-example YYYYMMDD matches both 20130710 and 201407.
-
-The format compiles two ways. cycle_regex renders the cycle span as capture
-group 1, for storing in Project.cycle_match so ordinary cycle resolution
-handles membership with no calver-specific code. order_key renders a
-named-group regex and extracts a comparable (year, month, day, serial) tuple
-for ordering releases within a cycle.
-"""
-
-from __future__ import annotations
-
-import functools
-import re
-from typing import TYPE_CHECKING, Final, NamedTuple
-
-if TYPE_CHECKING:
-    from collections.abc import Sequence
-
-# Longest tokens first so YYYY wins over YY and MM over M during the scan.
-_TOKENS: Final[list[tuple[str, str, str]]] = [
-    ("YYYY", r"\d{4}", "year"),
-    ("YY", r"\d{2}", "year"),
-    ("MM", r"\d{2}", "month"),
-    ("DD", r"\d{2}", "day"),
-    ("M", r"\d{1,2}", "month"),
-    ("D", r"\d{1,2}", "day"),
-    ("N", r"\d+", "serial"),
-]
-
-_CALENDAR_ROLES: Final[frozenset[str]] = frozenset({"year", "month", "day"})
-
-
-class _Atom(NamedTuple):
-    regex: str  # the regex fragment matching this token or literal
-    role: str | None  # the ordering role, or None for a literal separator
-    in_cycle: bool  # whether it sits inside the parenthesised cycle span
-
-
-def cycle_regex(date_format: str) -> str | None:
-    """Compile the cycle span to a regex with the cycle as group 1.
-
-    Returns None when the format has no parenthesised span - a linear calver
-    project that keeps a single default cycle, for which `cycle_match` stays
-    unset.
-    """
-    atoms = _parse(date_format)
-    cycle_indices = [i for i, atom in enumerate(atoms) if atom.in_cycle]
-    if not cycle_indices:
-        return None
-    first, last = cycle_indices[0], cycle_indices[-1]
-    before = "".join(atom.regex for atom in atoms[:first])
-    span = "".join(atom.regex for atom in atoms[first : last + 1])
-    tail = _nested_optional(_coalesce(atoms[last + 1 :]))
-    return f"^{before}({span}){tail}$"
-
-
-def order_key(date_format: str, version: str) -> tuple[int, int, int, int] | None:
-    """Resolve a version to a `(year, month, day, serial)` ordering tuple.
-
-    Absent fields read as 0, so a coarser version sorts below a finer one in
-    the same period. Returns None when the version doesn't fit the format.
-    """
-    match = _order_pattern(date_format).fullmatch(version)
-    if match is None:
-        return None
-    fields = match.groupdict()
-
-    def field(name: str) -> int:
-        captured = fields.get(name)
-        return int(captured) if captured else 0
-
-    return (field("year"), field("month"), field("day"), field("serial"))
-
-
-def validate(date_format: str) -> None:
-    """Raise ValueError if the format is malformed or carries no calendar field."""
-    if not date_format.strip():
-        raise ValueError("Calver format is empty")
-    atoms = _parse(date_format)
-    roles = [atom.role for atom in atoms if atom.role is not None]
-    if not any(role in _CALENDAR_ROLES for role in roles):
-        raise ValueError("Calver format needs at least one of YYYY/YY, MM/M or DD/D")
-    if len(roles) != len(set(roles)):
-        raise ValueError("Calver format repeats a field")
-    # Compile both regexes now, so an invalid format is caught here rather than
-    # when we first come to resolve a version against it.
-    re.compile(_order_regex(date_format))
-    compiled_cycle = cycle_regex(date_format)
-    if compiled_cycle is not None:
-        re.compile(compiled_cycle)
-
-
-def _coalesce(atoms: Sequence[_Atom]) -> list[str]:
-    # Fold each separator run onto the token that follows it, so a separator
-    # and its token become optional together rather than independently.
-    chunks: list[str] = []
-    pending = ""
-    for atom in atoms:
-        if atom.role is None:
-            pending += atom.regex
-            continue
-        chunks.append(pending + atom.regex)
-        pending = ""
-    if pending:
-        chunks.append(pending)
-    return chunks
-
-
-def _nested_optional(chunks: list[str]) -> str:
-    if not chunks:
-        return ""
-    return f"(?:{chunks[0]}{_nested_optional(chunks[1:])})?"
-
-
-@functools.lru_cache(maxsize=256)
-def _order_pattern(date_format: str) -> re.Pattern[str]:
-    return re.compile(_order_regex(date_format))
-
-
-def _order_regex(date_format: str) -> str:
-    chunks: list[str] = []
-    pending = ""
-    for atom in _parse(date_format):
-        if atom.role is None:
-            pending += atom.regex
-            continue
-        chunks.append(f"{pending}(?P<{atom.role}>{atom.regex})")
-        pending = ""
-    if pending:
-        chunks.append(pending)
-    if not chunks:
-        raise ValueError("Calver format has no fields")
-    # The leading field is required and the rest optional, which is what lets
-    # one format match both coarse and fine versions.
-    return f"^{chunks[0]}{_nested_optional(chunks[1:])}$"
-
-
-@functools.lru_cache(maxsize=256)
-def _parse(date_format: str) -> tuple[_Atom, ...]:
-    atoms: list[_Atom] = []
-    in_cycle = False
-    seen_cycle = False
-    i = 0
-    while i < len(date_format):
-        char = date_format[i]
-        if char == "(":
-            if seen_cycle:
-                raise ValueError("Calver format may contain at most one (cycle) span")
-            in_cycle = True
-            seen_cycle = True
-            i += 1
-            continue
-        if char == ")":
-            if not in_cycle:
-                raise ValueError("Calver format has an unmatched )")
-            in_cycle = False
-            i += 1
-            continue
-        token = _match_token(date_format, i)
-        if token is not None:
-            fragment, role, length = token
-            atoms.append(_Atom(fragment, role, in_cycle))
-            i += length
-            continue
-        atoms.append(_Atom(re.escape(char), None, in_cycle))
-        i += 1
-    if in_cycle:
-        raise ValueError("Calver format has an unmatched (")
-    return tuple(atoms)
-
-
-def _match_token(date_format: str, at: int) -> tuple[str, str, int] | None:
-    for token, fragment, role in _TOKENS:
-        if date_format.startswith(token, at):
-            return fragment, role, len(token)
-    return None
diff --git a/atr/cycles.py b/atr/cycles.py
index 31dcfab6..41cc45eb 100644
--- a/atr/cycles.py
+++ b/atr/cycles.py
@@ -30,7 +30,7 @@ from typing import TYPE_CHECKING, Final
 
 import semver
 
-import atr.calver as calver
+import atr.models.calver as calver
 import atr.models.sql as sql
 
 if TYPE_CHECKING:
diff --git a/atr/get/projects.py b/atr/get/projects.py
index be0df261..2a22dc27 100644
--- a/atr/get/projects.py
+++ b/atr/get/projects.py
@@ -427,6 +427,7 @@ def _asf_yaml_metadata(project: sql.Project) -> dict[str, object]:
         items = [item.strip() for item in (raw or "").split(",") if item.strip()]
         if items:
             metadata[field] = items
+    metadata.update(_asf_yaml_version_scheme(project))
     return metadata
 
 
@@ -508,6 +509,24 @@ def _asf_yaml_recipient_blocks(project: sql.Project) -> dict[str, object]:
     return policy
 
 
+def _asf_yaml_version_scheme(project: sql.Project) -> dict[str, object]:
+    scheme: dict[str, object] = {}
+    if project.version_method != sql.VersionMethod.SIMPLE:
+        scheme["version_method"] = project.version_method.value
+    if project.version_pattern:
+        scheme["version_pattern"] = project.version_pattern
+    # A calver project authors its date format, and cycle_match is compiled from
+    # it, so we emit whichever of the two the project actually sets
+    if project.version_method == sql.VersionMethod.CALVER:
+        if project.calver_format:
+            scheme["calver_format"] = project.calver_format
+    elif project.cycle_match:
+        scheme["cycle_match"] = project.cycle_match
+    if project.branch_template:
+        scheme["branch_template"] = project.branch_template
+    return scheme
+
+
 def _cycle_has_dates(cycle: sql.ProjectCycle) -> bool:
     return any(getattr(cycle, attr) is not None for attr in ("start", "begin", "latest", "eod", "eos", "eol"))
 
diff --git a/atr/models/__init__.py b/atr/models/__init__.py
index 0fbc8f0d..4e6fe964 100644
--- a/atr/models/__init__.py
+++ b/atr/models/__init__.py
@@ -19,6 +19,7 @@ from . import (
     api,
     args,
     basic,
+    calver,
     cap,
     distribution,
     github,
@@ -37,6 +38,7 @@ __all__ = [
     "api",
     "args",
     "basic",
+    "calver",
     "cap",
     "distribution",
     "github",
diff --git a/atr/models/api.py b/atr/models/api.py
index e8ee9785..f472310b 100644
--- a/atr/models/api.py
+++ b/atr/models/api.py
@@ -530,11 +530,12 @@ class ProjectConfigProjectArgs(schema.Strict):
     standards: list[str] | None = None
     categories: list[str] | None = None
     programming_languages: list[str] | None = None
-    # Changing any of these four reassigns existing releases to the cycle
-    # their version string now maps to.
+    # Changing any of these reassigns existing releases to the cycle their
+    # version string now maps to
     version_method: sql.VersionMethod | None = None
     version_pattern: str | None = None
     cycle_match: str | None = None
+    calver_format: str | None = None
     branch_template: str | None = None
 
     @pydantic.field_validator("version_method", mode="before")
@@ -547,6 +548,20 @@ class ProjectConfigProjectArgs(schema.Strict):
                 raise ValueError(f"'{v}' is not a valid VersionMethod")
         return v
 
+    @pydantic.model_validator(mode="after")
+    def _validate_calver_format(self) -> Self:
+        date_format = self.calver_format.strip() if self.calver_format else None
+        if date_format is None:
+            return self
+        if (self.version_method is not None) and (self.version_method is not sql.VersionMethod.CALVER):
+            raise ValueError("Field 'calver_format' applies only when version_method is 'calver'")
+        if self.cycle_match and self.cycle_match.strip():
+            raise ValueError("Set either 'calver_format' or 'cycle_match', not both")
+        from . import calver
+
+        calver.validate(date_format)
+        return self
+
     @pydantic.model_validator(mode="after")
     def _validate_version_scheme(self) -> Self:
         if ("version_method" in self.model_fields_set) and (self.version_method is None):
diff --git a/atr/shared/projects.py b/atr/shared/projects.py
index 7b079bd9..dfb31b06 100644
--- a/atr/shared/projects.py
+++ b/atr/shared/projects.py
@@ -22,10 +22,10 @@ from typing import Annotated, Literal
 
 import pydantic
 
-import atr.calver as calver
 import atr.config as config
 import atr.construct as construct
 import atr.form as form
+import atr.models.calver as calver
 import atr.models.safe as safe
 import atr.models.sql as sql
 import atr.models.validation as validation
diff --git a/atr/storage/writers/policy.py b/atr/storage/writers/policy.py
index f3fa3b36..df95d111 100644
--- a/atr/storage/writers/policy.py
+++ b/atr/storage/writers/policy.py
@@ -26,7 +26,6 @@ import sqlmodel
 import strictyaml
 import strictyaml.ruamel.error as error
 
-import atr.calver as calver
 import atr.catalog_site as catalog_site
 import atr.construct as construct
 import atr.cycles as cycles
@@ -518,10 +517,10 @@ def _calver_cycle_match(calver_format: str | None) -> str | None:
     if calver_format is None:
         return None
     try:
-        calver.validate(calver_format)
+        models.calver.validate(calver_format)
     except ValueError as exc:
         raise ValueError(f"Invalid calver format: {exc}") from exc
-    return calver.cycle_regex(calver_format)
+    return models.calver.cycle_regex(calver_format)
 
 
 def _collapse_to_default(submitted: str, default_text: str) -> str:
diff --git a/atr/storage/writers/project.py b/atr/storage/writers/project.py
index 6be8008f..71c6fe2b 100644
--- a/atr/storage/writers/project.py
+++ b/atr/storage/writers/project.py
@@ -29,6 +29,7 @@ import atr.constants as constants
 import atr.cycles as cycles
 import atr.db as db
 import atr.models.api as api
+import atr.models.calver as calver
 import atr.models.safe as safe
 import atr.models.sql as sql
 import atr.models.validation as validation
@@ -644,7 +645,13 @@ class FoundationAdmin(FoundationCommitter):
         }
         list_fields = {"repositories", "standards"}
         csv_fields = {"categories", "programming_languages"}
-        version_scheme_fields = {"version_method", "version_pattern", "cycle_match", "branch_template"}
+        version_scheme_fields = {
+            "version_method",
+            "version_pattern",
+            "cycle_match",
+            "calver_format",
+            "branch_template",
+        }
         provided = args.model_fields_set
 
         for field in str_fields & provided:
@@ -658,14 +665,34 @@ class FoundationAdmin(FoundationCommitter):
             items = [str(item).strip() for item in getattr(args, field) or []]
             joined = ", ".join(item for item in items if item)
             setattr(project, field, joined or None)
-        if "version_method" in provided:
-            project.version_method = args.version_method or sql.VersionMethod.SIMPLE
-
         if version_scheme_fields & provided:
+            _apply_version_scheme(project, args, provided)
             await cycles.reassign_release_cycles(self.__data, project)
             await catalog_site.queue_regeneration(self.__data, self.__asf_uid, project.key)
 
 
+def _apply_version_scheme(project: sql.Project, args: api.ProjectConfigProjectArgs, provided: set[str]) -> None:
+    if "version_method" in provided:
+        project.version_method = args.version_method or sql.VersionMethod.SIMPLE
+
+    if project.version_method is not sql.VersionMethod.CALVER:
+        if "calver_format" in provided:
+            raise ValueError("Field 'calver_format' applies only when version_method is 'calver'")
+        if "version_method" in provided:
+            # A project that has moved off calver keeps no date format behind
+            project.calver_format = None
+        return
+
+    # Cycle membership for a calver project comes from its date format, so
+    # cycle_match is compiled here rather than authored
+    if "cycle_match" in provided:
+        raise ValueError("Calver projects derive 'cycle_match' from 'calver_format'; set 'calver_format' instead")
+    if "calver_format" in provided:
+        project.calver_format = (args.calver_format or "").strip() or None
+    if {"calver_format", "version_method"} & provided:
+        project.cycle_match = calver.cycle_regex(project.calver_format) if project.calver_format else None
+
+
 async def _build_and_add_project(
     data: db.Session,
     asf_uid: str,
diff --git a/tests/unit/test_asf_yaml_export.py b/tests/unit/test_asf_yaml_export.py
index 902eadc7..0849fd3c 100644
--- a/tests/unit/test_asf_yaml_export.py
+++ b/tests/unit/test_asf_yaml_export.py
@@ -41,6 +41,11 @@ class _FakeProject:
             "standards": [],
             "categories": None,
             "programming_languages": None,
+            "version_method": sql.VersionMethod.SIMPLE,
+            "version_pattern": None,
+            "cycle_match": None,
+            "calver_format": None,
+            "branch_template": None,
             "release_policy": None,
             "update_type": sql.UpdateType.MANUAL,
         }
@@ -151,6 +156,24 @@ def test_export_includes_set_policy_fields() -> None:
     }
 
 
+def test_export_includes_the_date_format_a_calver_project_authors() -> None:
+    project = _FakeProject(
+        key="example",
+        committee_key="tooling",
+        name="Apache Example",
+        version_method=sql.VersionMethod.CALVER,
+        calver_format="(YY.MM).N",
+        cycle_match="^(\\d{2}\\.\\d{2})(?:\\.\\d+)?$",
+    )
+
+    metadata = strictyaml.load(projects._asf_yaml_export(project)).data["project"]["metadata"]
+
+    # cycle_match is compiled from the date format on import, so exporting it too
+    # would offer two sources for one setting
+    assert metadata["calver_format"] == "(YY.MM).N"
+    assert "cycle_match" not in metadata
+
+
 def test_export_minimal_project_omits_empty_blocks() -> None:
     project = _FakeProject(key="trusted-releases", committee_key="tooling", name="Apache Trusted Releases")
 
@@ -196,6 +219,10 @@ def test_export_reproduces_every_field() -> None:
         standards=["https://owasp.org/www-project-application-security-verification-standard/"],
         categories="build-management",
         programming_languages="python",
+        version_method=sql.VersionMethod.SEMVER,
+        version_pattern=r"^\d+\.\d+\.\d+$",
+        cycle_match=r"^(\d+)\.\d+\.\d+$",
+        branch_template="release-{cycle}",
         recipients={
             sql.RecipientAction.VOTE: {"to": "private@tooling.apache.org", "cc": ["dev@tooling.apache.org"]},
             sql.RecipientAction.ANNOUNCE: {"to": "announce@apache.org"},
@@ -224,6 +251,10 @@ def test_export_reproduces_every_field() -> None:
                 "standards": ["https://owasp.org/www-project-application-security-verification-standard/"],
                 "categories": ["build-management"],
                 "programming_languages": ["python"],
+                "version_method": "semver",
+                "version_pattern": r"^\d+\.\d+\.\d+$",
+                "cycle_match": r"^(\d+)\.\d+\.\d+$",
+                "branch_template": "release-{cycle}",
             },
             "policy": {
                 "vote_recipients": {"to": "private@tooling.apache.org", "cc": ["dev@tooling.apache.org"]},
diff --git a/tests/unit/test_calver.py b/tests/unit/test_calver.py
index f8d327e5..6a15d69b 100644
--- a/tests/unit/test_calver.py
+++ b/tests/unit/test_calver.py
@@ -19,7 +19,7 @@ import re
 
 import pytest
 
-import atr.calver as calver
+import atr.models.calver as calver
 
 
 def test_order_key_compares_dotted_non_padded_dates_numerically():
diff --git a/tests/unit/test_cycles.py b/tests/unit/test_cycles.py
index a4177994..b3340d8b 100644
--- a/tests/unit/test_cycles.py
+++ b/tests/unit/test_cycles.py
@@ -20,8 +20,8 @@ from types import SimpleNamespace
 
 import pytest
 
-import atr.calver as calver
 import atr.cycles as cycles
+import atr.models.calver as calver
 import atr.models.sql as sql
 
 
diff --git a/tests/unit/test_project_config_args.py b/tests/unit/test_project_config_args.py
index 461c957c..fe82c1fd 100644
--- a/tests/unit/test_project_config_args.py
+++ b/tests/unit/test_project_config_args.py
@@ -21,6 +21,9 @@ import pydantic
 import pytest
 
 import atr.models.api as api
+import atr.models.calver as calver
+import atr.models.sql as sql
+import atr.storage.writers.project as project_writer
 
 
 def test_project_config_accepts_announce_recipients_on_any_apache_address() -> None:
@@ -96,6 +99,63 @@ def test_project_config_accepts_download_path_suffix() -> None:
     assert args.policy.download_path_suffix == "{{PROJECT_KEY}}-{{VERSION}}"
 
 
+def test_project_config_accepts_calver_format_for_a_calver_project() -> None:
+    args = _project_config_project(version_method="calver", calver_format="(YY.MM).N")
+
+    assert args.project is not None
+    assert args.project.calver_format == "(YY.MM).N"
+
+
+def test_project_config_rejects_calver_format_for_another_version_method() -> None:
+    with pytest.raises(pydantic.ValidationError, match=re.escape("applies only when version_method is 'calver'")):
+        _project_config_project(version_method="semver", calver_format="(YY.MM).N")
+
+
+def test_project_config_rejects_a_hand_written_cycle_match_beside_a_date_format() -> None:
+    with pytest.raises(pydantic.ValidationError, match=re.escape("not both")):
+        _project_config_project(calver_format="(YY.MM).N", cycle_match=r"^(\d+)\.")
+
+
+def test_project_config_rejects_a_malformed_date_format() -> None:
+    with pytest.raises(pydantic.ValidationError, match=re.escape("unmatched (")):
+        _project_config_project(version_method="calver", calver_format="(YY.MM")
+
+
+def test_calver_format_compiles_the_cycle_match() -> None:
+    project = _project()
+
+    _apply_version_scheme(project, version_method="calver", calver_format="(YY.MM).N")
+
+    assert project.version_method == sql.VersionMethod.CALVER
+    assert project.cycle_match == calver.cycle_regex("(YY.MM).N")
+
+
+def test_leaving_calver_clears_the_date_format() -> None:
+    project = _project(version_method=sql.VersionMethod.CALVER, calver_format="(YY.MM).N")
+
+    _apply_version_scheme(project, version_method="semver", cycle_match=r"^(\d+)\.\d+\.\d+$")
+
+    assert project.version_method == sql.VersionMethod.SEMVER
+    assert project.calver_format is None
+
+
+def test_editing_an_unrelated_field_leaves_a_stored_cycle_match_alone() -> None:
+    project = _project(version_method=sql.VersionMethod.CALVER, calver_format="(YY.MM).N", cycle_match="^(2025)")
+
+    _apply_version_scheme(project, branch_template="release-{cycle}")
+
+    assert project.cycle_match == "^(2025)"
+
+
+def _apply_version_scheme(project: sql.Project, **fields: object) -> None:
+    args = api.ProjectConfigProjectArgs.model_validate(fields)
+    project_writer._apply_version_scheme(project, args, args.model_fields_set)
+
+
+def _project(**fields: object) -> sql.Project:
+    return sql.Project(key="example", name="Apache Example", committee_key="tooling", **fields)
+
+
 def _project_config(**policy: object) -> api.ProjectConfigArgs:
     return api.ProjectConfigArgs.model_validate(
         {"project_key": "tooling", "committee_key": "tooling", "policy": policy}


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org