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/05/07 15:52:15 UTC

(tooling-trusted-releases) 01/01: #912/#914 - cycle and version-scheme editing, release events on announce

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

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

commit 4554cc562e93f1569b4cdd9f695da8cf66660536
Author: Alastair McFarlane <ar...@apache.org>
AuthorDate: Thu May 7 16:51:55 2026 +0100

    #912/#914 -  cycle and version-scheme editing, release events on announce
---
 atr/form.py                      |  26 ++++
 atr/get/projects.py              | 232 +++++++++++++++++++++++++++++----
 atr/htm.py                       |  50 +++++++-
 atr/post/projects.py             |  88 +++++++++----
 atr/shared/projects.py           |  50 ++++++++
 atr/storage/writers/announce.py  |  16 ++-
 atr/storage/writers/policy.py    | 112 ++++++++++++++++
 atr/storage/writers/release.py   |   8 ++
 tests/unit/test_policy_writer.py | 270 +++++++++++++++++++++++++++++++++++++++
 9 files changed, 799 insertions(+), 53 deletions(-)

diff --git a/atr/form.py b/atr/form.py
index 06faf8c3..77f87695 100644
--- a/atr/form.py
+++ b/atr/form.py
@@ -17,6 +17,7 @@
 
 from __future__ import annotations
 
+import datetime
 import enum
 import pathlib
 import re
@@ -63,6 +64,7 @@ class Widget(enum.Enum):
     CHECKBOX = "checkbox"
     CHECKBOXES = "checkboxes"
     CUSTOM = "custom"
+    DATE = "date"
     EMAIL = "email"
     FILE = "file"
     FILES = "files"
@@ -499,6 +501,12 @@ Bool = Annotated[
     pydantic.Field(default=False),
 ]
 
+OptionalDate = Annotated[
+    datetime.date | None,
+    functional_validators.BeforeValidator(lambda v: None if v == "" else v),
+    pydantic.Field(default=None),
+]
+
 Email = pydantic.EmailStr
 
 
@@ -713,6 +721,9 @@ def _get_widget_type(field_info: pydantic.fields.FieldInfo) -> Widget:  # noqa:
     if annotation is bool:
         return Widget.CHECKBOX
 
+    if annotation is datetime.date:
+        return Widget.DATE
+
     if annotation is pydantic.EmailStr:
         return Widget.EMAIL
 
@@ -810,6 +821,12 @@ def _render_field_value(
         field_value = flash_error_data[field_name]["original"]
     elif has_flash_data:
         field_value = flash_error_data[f"!{field_name}"]["original"]
+    elif flash_error_data and (_get_widget_type(field_info) == Widget.CHECKBOX):
+        # An unticked checkbox is absent from form data, so doesn't appear in
+        # flash_error_data either. In flash-rerender mode treat as unticked
+        # rather than falling back to the default - otherwise the checkbox
+        # appears ticked even though the user submitted it unticked.
+        field_value = False
     elif defaults:
         field_value = defaults.get(field_name)
     elif not field_info.is_required():
@@ -971,6 +988,15 @@ def _render_widget(  # noqa: C901
             else:
                 widget = htm.div[f"Custom widget for {field_name} not provided"]
 
+        case Widget.DATE:
+            attrs = {**base_attrs, "type": "date"}
+            if isinstance(field_value, datetime.date):
+                attrs["value"] = field_value.isoformat()
+            elif field_value:
+                # Already a string (eg from form-flash data round-tripped via JSON).
+                attrs["value"] = str(field_value)
+            widget = htpy.input(**attrs)
+
         case Widget.EMAIL:
             attrs = {**base_attrs, "type": "email"}
             if field_value:
diff --git a/atr/get/projects.py b/atr/get/projects.py
index 6c219a7e..d6be4099 100644
--- a/atr/get/projects.py
+++ b/atr/get/projects.py
@@ -21,6 +21,7 @@ from typing import Literal
 
 import asfquart.base as base
 import htpy
+import quart
 import strictyaml
 
 import atr.blueprints.get as get
@@ -158,8 +159,9 @@ async def view(
         project = await data.project(
             key=str(project_key), _committee=True, _committee_public_signing_keys=True, _release_policy=True
         ).demand(base.ASFQuartException(f"Project {project_key} not found", errorcode=404))
+        cycles = list(await data.project_cycle(project_key=str(project_key)).all())
 
-    is_committee_member = project.committee and (user.is_committee_member(project.committee, session.uid))
+    is_committee_member = bool(project.committee and user.is_committee_member(project.committee, session.uid))
     is_privileged = session.is_admin
     can_edit = is_committee_member or is_privileged
 
@@ -186,33 +188,59 @@ async def view(
         else "",
     ]
     page.append(title_row)
-
-    page.p(".mb-4")[
-        htm.a(".btn.btn-sm.btn-outline-primary", href=util.as_url(start.selected, project_key=str(project.key)))[
-            "Start a new release"
-        ]
-    ]
-
     page.append(_render_project_label_card(project))
     page.append(_render_pmc_card(project))
-    page.append(_render_description_card(project))
+
+    tab_items: list[htm.Tab] = []
+
+    if is_committee_member or is_privileged:
+        tab_items.append(
+            htm.Tab(
+                key="releases",
+                label="Releases",
+                render=lambda: _render_releases_tab(
+                    project,
+                    cycles,
+                    candidate_drafts,
+                    candidates,
+                    previews,
+                    full_releases,
+                    can_edit=can_edit,
+                ),
+            )
+        )
+
+    tab_items.append(
+        htm.Tab(
+            key="metadata",
+            label="Metadata",
+            render=lambda: _render_metadata_tab(project, can_edit=can_edit),
+        )
+    )
 
     if project.status == sql.ProjectStatus.ACTIVE:
         if can_edit:
-            page.append(await _render_compose_form(project))
-            page.append(await _render_vote_form(project))
-            page.append(await _render_finish_form(project))
-            page.append(await _render_trusted_publishing_form(project))
+            tab_items.extend(
+                [
+                    htm.Tab("compose", "Compose", lambda: _render_compose_form(project)),
+                    htm.Tab("vote", "Vote", lambda: _render_vote_form(project)),
+                    htm.Tab("finish", "Finish", lambda: _render_finish_form(project)),
+                    htm.Tab(
+                        "trusted-publishing",
+                        "Trusted publishing",
+                        lambda: _render_trusted_publishing_form(project),
+                    ),
+                ]
+            )
         else:
-            page.append(_render_policy_readonly(project))
+            tab_items.append(htm.Tab("policy", "Policy", lambda: _render_policy_readonly(project)))
 
-    if can_edit:
-        page.append(_render_categories_section(project))
-        page.append(_render_languages_section(project))
+    active_tab = quart.request.args.get("tab", tab_items[0].key)
+    base_url = util.as_url(view, project_key=str(project.key))
+    page.append(await htm.tabs(tab_items, active_key=active_tab, base_url=base_url))
 
+    # Below the tabs: admin-only project actions.
     if is_committee_member or is_privileged:
-        page.append(await _render_releases_sections(project, candidate_drafts, candidates, previews, full_releases))
-
         if project.created_by == session.uid:
             page.append(await _render_delete_section(project))
 
@@ -236,6 +264,10 @@ async def view(
     )
 
 
+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"))
+
+
 def _input_with_variables(
     field_name: str,
     default_value: str,
@@ -367,6 +399,72 @@ async def _render_compose_form(project: sql.Project) -> htm.Element:
     return card.collect()
 
 
+def _cycle_display_name(cycle: sql.ProjectCycle) -> str:
+    # The "default" cycle is what every project gets when it has no cycle_match
+    # set, so showing the literal "default" in headings is jargon. Render it as
+    # "No cycle" instead; named cycles render as their name.
+    return "No cycle" if cycle.cycle == "default" else f"Cycle {cycle.cycle}"
+
+
+def _render_cycle_dates_card(cycle: sql.ProjectCycle) -> htm.Element:
+    card = htm.Block(htm.details, classes=".card.mb-4")
+    card.summary(".card-header.bg-light")[htm.h3(".mb-0.d-inline-block")["Cycle dates"]]
+    rows = []
+    # Order matches the rough lifecycle reading: when did things start, when
+    # do they wind down, and is this an LTS line.
+    # Labels match the column semantics in #912.
+    date_fields: list[tuple[str, str]] = [
+        ("First release candidate", "begin"),
+        ("First release", "start"),
+        ("Latest release", "latest"),
+        ("End of development", "eod"),
+        ("End of support", "eos"),
+        ("End of life", "eol"),
+    ]
+    for label, attr in date_fields:
+        value = getattr(cycle, attr)
+        value_cell = (
+            htm.td[value.strftime("%Y-%m-%d")] if value is not None else htm.td(".text-muted.fst-italic")["Not set"]
+        )
+        rows.append(
+            htm.tr[
+                htm.td(".pe-3.text-muted")[label],
+                value_cell,
+            ]
+        )
+    if cycle.lts:
+        rows.append(
+            htm.tr[
+                htm.td(".pe-3.text-muted")["Long-term support"],
+                htm.td[htm.span(".badge.text-bg-info")["LTS"]],
+            ]
+        )
+    with card.block(htm.div, classes=".card-body") as body:
+        body.append(htm.table(".table.table-sm.mb-0")[*rows])
+    return card.collect()
+
+
+async def _render_cycle_dates_form(project: sql.Project, cycle: sql.ProjectCycle) -> htm.Element:
+    card = htm.Block(htm.details, classes=".card.mb-4")
+    card.summary(".card-header.bg-light")[htm.h3(".mb-0.d-inline-block")["Cycle dates"]]
+    with card.block(htm.div, classes=".card-body") as body:
+        await form.render_block(
+            body,
+            model_cls=shared.projects.EditCycleDatesForm,
+            action=util.as_url(post.projects.view, name=str(project.key)),
+            submit_label="Save cycle dates",
+            defaults={
+                "project_key": str(project.key),
+                "cycle_key": cycle.cycle_key,
+                "eod": cycle.eod.date() if cycle.eod is not None else None,
+                "eos": cycle.eos.date() if cycle.eos is not None else None,
+                "eol": cycle.eol.date() if cycle.eol is not None else None,
+                "lts": cycle.lts,
+            },
+        )
+    return card.collect()
+
+
 async def _render_delete_section(project: sql.Project) -> htm.Element:
     section = htm.Block(htm.div)
     section.h2["Actions"]
@@ -480,6 +578,16 @@ def _render_languages_section(project: sql.Project) -> htm.Element:
     return card.collect()
 
 
+async def _render_metadata_tab(project: sql.Project, *, can_edit: bool) -> htm.Element:
+    block = htm.Block()
+    block.append(_render_description_card(project))
+    if can_edit:
+        block.append(await _render_version_scheme_form(project))
+        block.append(_render_categories_section(project))
+        block.append(_render_languages_section(project))
+    return block.collect()
+
+
 def _render_pmc_card(project: sql.Project) -> htm.Element:
     card = htm.Block(htm.div, classes=".card.mb-4")
     card.div(".card-header.bg-light")[htm.h3(".mb-2")["PMC"]]
@@ -535,11 +643,17 @@ async def _render_releases_sections(
     candidates: list[sql.Release],
     previews: list[sql.Release],
     full_releases: list[sql.Release],
+    *,
+    nested: bool = False,
 ) -> htm.Element:
+    # When `nested` is true the section sits beneath a cycle heading (h2), so
+    # we demote the section titles to h3. Otherwise keep them as h2 so the
+    # tab content has top-level structure.
+    heading = htm.h3 if nested else htm.h2
     sections = htm.Block(htm.div)
 
     if candidate_drafts:
-        sections.h2["Draft candidate releases"]
+        sections.append(heading["Draft candidate releases"])
         draft_buttons = []
         for drf in candidate_drafts:
             file_count = await util.number_of_release_files(drf)
@@ -556,7 +670,7 @@ async def _render_releases_sections(
         sections.div(".d-flex.flex-wrap.gap-2.mb-4")[*draft_buttons]
 
     if candidates:
-        sections.h2["Candidate releases"]
+        sections.append(heading["Candidate releases"])
         candidate_buttons = []
         for cnd in candidates:
             file_count = await util.number_of_release_files(cnd)
@@ -573,7 +687,7 @@ async def _render_releases_sections(
         sections.div(".d-flex.flex-wrap.gap-2.mb-4")[*candidate_buttons]
 
     if previews:
-        sections.h2["Preview releases"]
+        sections.append(heading["Preview releases"])
         preview_buttons = []
         for prv in previews:
             file_count = await util.number_of_release_files(prv)
@@ -590,7 +704,7 @@ async def _render_releases_sections(
         sections.div(".d-flex.flex-wrap.gap-2.mb-4")[*preview_buttons]
 
     if full_releases:
-        sections.h2["Full releases"]
+        sections.append(heading["Full releases"])
         release_buttons = []
         for rel in full_releases:
             file_count = await util.number_of_release_files(rel)
@@ -609,6 +723,58 @@ async def _render_releases_sections(
     return sections.collect()
 
 
+async def _render_releases_tab(
+    project: sql.Project,
+    cycles: list[sql.ProjectCycle],
+    candidate_drafts: list[sql.Release],
+    candidates: list[sql.Release],
+    previews: list[sql.Release],
+    full_releases: list[sql.Release],
+    *,
+    can_edit: bool,
+) -> htm.Element:
+    block = htm.Block()
+    block.p(".mb-4")[
+        htm.a(
+            ".btn.btn-sm.btn-outline-primary",
+            href=util.as_url(start.selected, project_key=str(project.key)),
+        )["Start a new release"]
+    ]
+
+    # Stay flat for projects with only the implicit "default" cycle and no
+    # cycle dates set. Once cycles get used or dates get filled in, headings
+    # surface automatically. The card / form surfaces whenever can_edit, so a
+    # PMC of a simple-default project can still set eod/eos/eol/lts.
+    show_cycle_heading = (len(cycles) > 1) or any(c.cycle != "default" for c in cycles)
+
+    for cycle in cycles:
+        cycle_drafts = [r for r in candidate_drafts if r.cycle_key == cycle.cycle_key]
+        cycle_candidates = [r for r in candidates if r.cycle_key == cycle.cycle_key]
+        cycle_previews = [r for r in previews if r.cycle_key == cycle.cycle_key]
+        cycle_full = [r for r in full_releases if r.cycle_key == cycle.cycle_key]
+
+        cycle_has_dates = _cycle_has_dates(cycle)
+        cycle_has_releases = bool(cycle_drafts or cycle_candidates or cycle_previews or cycle_full)
+        if not (cycle_has_dates or cycle_has_releases or show_cycle_heading or can_edit):
+            continue
+
+        if show_cycle_heading:
+            block.h2(".mt-4.mb-3")[_cycle_display_name(cycle)]
+
+        if can_edit:
+            block.append(await _render_cycle_dates_form(project, cycle))
+        elif cycle_has_dates:
+            block.append(_render_cycle_dates_card(cycle))
+
+        block.append(
+            await _render_releases_sections(
+                project, cycle_drafts, cycle_candidates, cycle_previews, cycle_full, nested=show_cycle_heading
+            )
+        )
+
+    return block.collect()
+
+
 async def _render_trusted_publishing_form(project: sql.Project) -> htm.Element:
     card = htm.Block(htm.div, classes=".card.mb-4")
     card.div(".card-header.bg-light.d-flex.justify-content-between.align-items-center")[
@@ -636,6 +802,26 @@ async def _render_trusted_publishing_form(project: sql.Project) -> htm.Element:
     return card.collect()
 
 
+async def _render_version_scheme_form(project: sql.Project) -> htm.Element:
+    card = htm.Block(htm.div, classes=".card.mb-4")
+    card.div(".card-header.bg-light")[htm.h3(".mb-0")["Version scheme"]]
+    with card.block(htm.div, classes=".card-body") as body:
+        await form.render_block(
+            body,
+            model_cls=shared.projects.EditVersionSchemeForm,
+            action=util.as_url(post.projects.view, name=str(project.key)),
+            submit_label="Save",
+            defaults={
+                "project_key": str(project.key),
+                "version_method": project.version_method,
+                "version_pattern": project.version_pattern or "",
+                "cycle_match": project.cycle_match or "",
+                "branch_template": project.branch_template or "",
+            },
+        )
+    return card.collect()
+
+
 async def _render_vote_form(project: sql.Project) -> htm.Element:
     card = htm.Block(htm.div, classes=".card.mb-4")
     card.div(".card-header.bg-light.d-flex.justify-content-between.align-items-center")[
diff --git a/atr/htm.py b/atr/htm.py
index f8355cd3..6a559dcb 100644
--- a/atr/htm.py
+++ b/atr/htm.py
@@ -18,6 +18,8 @@
 from __future__ import annotations
 
 import contextlib
+import dataclasses
+import inspect
 from typing import TYPE_CHECKING, Any
 
 import htpy
@@ -26,7 +28,7 @@ import markupsafe
 from . import log
 
 if TYPE_CHECKING:
-    from collections.abc import Callable, Generator
+    from collections.abc import Awaitable, Callable, Generator
 
 type Element = htpy.Element
 type VoidElement = htpy.VoidElement
@@ -288,3 +290,49 @@ def icon(name: str, classes="") -> Element:
 def ul_links(*items: tuple[str, str]) -> Element:
     li_items = [li[a(href=item[0])[item[1]]] for item in items]
     return ul[*li_items]
+
+
+@dataclasses.dataclass(frozen=True)
+class Tab:
+    """One tab in a tab strip rendered by `tabs()`.
+
+    `render` is a thunk - sync or async - returning the tab's body. Only
+    the active tab's render is invoked, so inactive tabs are cheap.
+    """
+
+    key: str
+    label: str
+    render: Callable[[], Element | Awaitable[Element]]
+
+
+async def tabs(items: list[Tab], *, active_key: str, base_url: str) -> Element:
+    """Render a Bootstrap nav-tabs strip plus the active tab's content.
+
+    Tab navigation is via full page load: each tab link targets `base_url`
+    with `?tab=<key>` appended, so every tab is its own bookmarkable and
+    history-tracked URL. No client-side JS is used.
+
+    If `active_key` doesn't match any tab the first item is rendered as a
+    fallback.
+    """
+    if not items:
+        raise ValueError("tabs() requires at least one tab")
+    if not any(item.key == active_key for item in items):
+        active_key = items[0].key
+
+    nav_items = []
+    for item in items:
+        link_classes = ".nav-link.active" if item.key == active_key else ".nav-link"
+        nav_items.append(
+            li(".nav-item", role="presentation")[
+                a(link_classes, href=f"{base_url}?tab={item.key}", role="tab")[item.label]
+            ]
+        )
+    nav = ul(".nav.nav-tabs.mb-3", role="tablist")[*nav_items]
+
+    active_item = next(item for item in items if item.key == active_key)
+    content = active_item.render()
+    if inspect.isawaitable(content):
+        content = await content
+
+    return div[nav, content]
diff --git a/atr/post/projects.py b/atr/post/projects.py
index 505f9da1..edc5c297 100644
--- a/atr/post/projects.py
+++ b/atr/post/projects.py
@@ -17,7 +17,7 @@
 
 from __future__ import annotations
 
-from typing import Literal
+from typing import TYPE_CHECKING, Any, Final, Literal
 
 import quart
 
@@ -29,6 +29,9 @@ import atr.shared as shared
 import atr.storage as storage
 import atr.web as web
 
+if TYPE_CHECKING:
+    from collections.abc import Awaitable, Callable
+
 
 @post.typed
 async def add_project(
@@ -94,33 +97,7 @@ async def view(
     """
     URL: /projects/<name>
     """
-    match project_form:
-        case shared.projects.AddCategoryForm() as add_category_form:
-            return await _process_add_category(session, add_category_form)
-
-        case shared.projects.AddLanguageForm() as add_language_form:
-            return await _process_add_language(session, add_language_form)
-
-        case shared.projects.ComposePolicyForm() as compose_form:
-            return await _process_compose_form(session, compose_form)
-
-        case shared.projects.DeleteProjectForm() as delete_form:
-            return await _process_delete_project(session, delete_form)
-
-        case shared.projects.FinishPolicyForm() as finish_form:
-            return await _process_finish_form(session, finish_form)
-
-        case shared.projects.TrustedPublishingPolicyForm() as tp_form:
-            return await _process_trusted_publishing_form(session, tp_form)
-
-        case shared.projects.RemoveCategoryForm() as remove_form:
-            return await _process_remove_category(session, remove_form)
-
-        case shared.projects.RemoveLanguageForm() as remove_form:
-            return await _process_remove_language(session, remove_form)
-
-        case shared.projects.VotePolicyForm() as vote_form:
-            return await _process_vote_form(session, vote_form)
+    return await _VIEW_HANDLERS[type(project_form)](session, project_form)
 
 
 async def _metadata_category_add(
@@ -235,6 +212,46 @@ async def _process_delete_project(
     return await session.redirect(get.projects.projects, success=f"Project '{project_key}' deleted successfully.")
 
 
+async def _process_edit_cycle_dates_form(
+    session: web.Committer, edit_form: shared.projects.EditCycleDatesForm
+) -> web.WerkzeugResponse:
+    project_key = edit_form.project_key
+
+    async with storage.write(session) as write:
+        wacm = await write.as_project_committee_member(project_key)
+        try:
+            await wacm.policy.edit_cycle_dates(edit_form)
+        except storage.AccessError as e:
+            return await session.redirect(
+                get.projects.view, project_key=str(project_key), error=f"Error saving cycle dates: {e}"
+            )
+        except ValueError as e:
+            return await session.redirect(get.projects.view, project_key=str(project_key), error=str(e))
+
+    return await session.redirect(get.projects.view, project_key=str(project_key), success="Cycle dates saved.")
+
+
+async def _process_edit_version_scheme_form(
+    session: web.Committer, edit_form: shared.projects.EditVersionSchemeForm
+) -> web.WerkzeugResponse:
+    project_key = edit_form.project_key
+
+    async with storage.write(session) as write:
+        wacm = await write.as_project_committee_member(project_key)
+        try:
+            await wacm.policy.edit_version_scheme(edit_form)
+        except storage.AccessError as e:
+            return await session.redirect(
+                get.projects.view,
+                project_key=str(project_key),
+                error=f"Error saving version scheme: {e}",
+            )
+        except ValueError as e:
+            return await session.redirect(get.projects.view, project_key=str(project_key), error=str(e))
+
+    return await session.redirect(get.projects.view, project_key=str(project_key), success="Version scheme saved.")
+
+
 async def _process_finish_form(
     session: web.Committer, finish_form: shared.projects.FinishPolicyForm
 ) -> web.WerkzeugResponse:
@@ -326,3 +343,18 @@ async def _process_vote_form(session: web.Committer, vote_form: shared.projects.
     return await session.redirect(
         get.projects.view, project_key=project_key, success="Vote options saved successfully."
     )
+
+
+_VIEW_HANDLERS: Final[dict[type, Callable[[web.Committer, Any], Awaitable[web.WerkzeugResponse]]]] = {
+    shared.projects.AddCategoryForm: _process_add_category,
+    shared.projects.AddLanguageForm: _process_add_language,
+    shared.projects.ComposePolicyForm: _process_compose_form,
+    shared.projects.DeleteProjectForm: _process_delete_project,
+    shared.projects.EditCycleDatesForm: _process_edit_cycle_dates_form,
+    shared.projects.EditVersionSchemeForm: _process_edit_version_scheme_form,
+    shared.projects.FinishPolicyForm: _process_finish_form,
+    shared.projects.TrustedPublishingPolicyForm: _process_trusted_publishing_form,
+    shared.projects.RemoveCategoryForm: _process_remove_category,
+    shared.projects.RemoveLanguageForm: _process_remove_language,
+    shared.projects.VotePolicyForm: _process_vote_form,
+}
diff --git a/atr/shared/projects.py b/atr/shared/projects.py
index cc4576f4..04b2d851 100644
--- a/atr/shared/projects.py
+++ b/atr/shared/projects.py
@@ -29,6 +29,8 @@ import atr.models.validation as validation
 import atr.util as util
 
 type COMPOSE = Literal["compose"]
+type EDIT_CYCLE_DATES = Literal["edit_cycle_dates"]
+type EDIT_VERSION_SCHEME = Literal["edit_version_scheme"]
 type FINISH = Literal["finish"]
 type TRUSTED_PUBLISHING = Literal["trusted_publishing"]
 type VOTE = Literal["vote"]
@@ -178,6 +180,52 @@ class VotePolicyForm(form.Form):
         return self
 
 
+class EditCycleDatesForm(form.Form):
+    variant: EDIT_CYCLE_DATES = form.value(EDIT_CYCLE_DATES)
+    project_key: safe.ProjectKey = form.label("Project name", widget=form.Widget.HIDDEN)
+    cycle_key: str = form.label("Cycle key", widget=form.Widget.HIDDEN)
+    eod: form.OptionalDate = form.label(
+        "End of development",
+        "When active development on this cycle is planned to stop.",
+    )
+    eos: form.OptionalDate = form.label(
+        "End of support",
+        "When this cycle is planned to stop receiving support.",
+    )
+    eol: form.OptionalDate = form.label(
+        "End of life",
+        "When this cycle is planned to be retired entirely.",
+    )
+    lts: form.Bool = form.label(
+        "Long-term support",
+        "Mark this cycle as a long-term support line.",
+    )
+
+
+class EditVersionSchemeForm(form.Form):
+    variant: EDIT_VERSION_SCHEME = form.value(EDIT_VERSION_SCHEME)
+    project_key: safe.ProjectKey = form.label("Project name", widget=form.Widget.HIDDEN)
+    version_method: form.Enum[sql.VersionMethod] = form.label(
+        "Version method",
+        "How this project numbers releases. Determines how cycles are derived from versions.",
+        widget=form.Widget.RADIO,
+    )
+    version_pattern: str = form.label(
+        "Version pattern",
+        "Optional regex that release versions must match. Leave empty to accept any version.",
+    )
+    cycle_match: str = form.label(
+        "Cycle match",
+        "Regex applied to the version string; capture group 1 is the cycle name."
+        r" For example, (\d+)\..* maps version 2.0.1 into cycle 2."
+        " Leave empty for projects that keep a single default cycle.",
+    )
+    branch_template: str = form.label(
+        "Branch template",
+        "Optional naming hint for source branches per cycle. Not currently enforced.",
+    )
+
+
 class FinishPolicyForm(form.Form):
     variant: FINISH = form.value(FINISH)
     project_key: safe.ProjectKey = form.label("Project name", widget=form.Widget.HIDDEN)
@@ -280,6 +328,8 @@ class DeleteSelectedProject(form.Form):
 
 type ProjectViewForm = Annotated[
     ComposePolicyForm
+    | EditCycleDatesForm
+    | EditVersionSchemeForm
     | FinishPolicyForm
     | TrustedPublishingPolicyForm
     | VotePolicyForm
diff --git a/atr/storage/writers/announce.py b/atr/storage/writers/announce.py
index 6171e0f1..0abff67f 100644
--- a/atr/storage/writers/announce.py
+++ b/atr/storage/writers/announce.py
@@ -251,6 +251,17 @@ class CommitteeMember(CommitteeParticipant):
             self.__data.add(task)
 
             await self.__promote_in_database(release, preview_revision_number, release_date)
+            self.__data.add(
+                sql.LifecycleEvent(
+                    project_key=release.project_key,
+                    cycle_key=release.cycle_key,
+                    version_key=release.key,
+                    event=sql.LifecycleEventType.RELEASE,
+                    effective=release_date,
+                    published=release_date,
+                    reference_urls=[f"https://lists.apache.org/list.html?{email_to}"],
+                )
+            )
             await self.__data.commit()
         except storage.AccessError:
             raise
@@ -292,7 +303,10 @@ class CommitteeMember(CommitteeParticipant):
         return predicted_finished_release
 
     async def __promote_in_database(
-        self, release: sql.Release, preview_revision_number: safe.RevisionNumber, release_date: datetime.datetime
+        self,
+        release: sql.Release,
+        preview_revision_number: safe.RevisionNumber,
+        release_date: datetime.datetime,
     ) -> None:
         """Promote a release preview to a release and delete its old revisions."""
         via = sql.validate_instrumented_attribute
diff --git a/atr/storage/writers/policy.py b/atr/storage/writers/policy.py
index 30f47dea..8b63fc8d 100644
--- a/atr/storage/writers/policy.py
+++ b/atr/storage/writers/policy.py
@@ -18,8 +18,11 @@
 # Removing this will cause circular imports
 from __future__ import annotations
 
+import datetime
+import re
 from typing import TYPE_CHECKING, Any, Final
 
+import sqlmodel
 import strictyaml
 import strictyaml.ruamel.error as error
 
@@ -139,6 +142,80 @@ class CommitteeMember(CommitteeParticipant):
 
         await self.__commit_and_log(str(project_key))
 
+    async def edit_cycle_dates(self, form: shared.projects.EditCycleDatesForm) -> None:
+        project_key = form.project_key
+        cycle = await self.__data.project_cycle(cycle_key=form.cycle_key).demand(
+            storage.AccessError(f"Cycle {form.cycle_key} not found")
+        )
+        if cycle.project_key != str(project_key):
+            raise storage.AccessError(f"Cycle {form.cycle_key} does not belong to project {project_key}")
+
+        via = models.sql.validate_instrumented_attribute
+        now = datetime.datetime.now(datetime.UTC)
+        # Each entry: form/column attribute name and the matching event type.
+        date_fields: list[tuple[str, models.sql.LifecycleEventType]] = [
+            ("eod", models.sql.LifecycleEventType.EOD),
+            ("eos", models.sql.LifecycleEventType.EOS),
+            ("eol", models.sql.LifecycleEventType.EOL),
+        ]
+        for attr, event_type in date_fields:
+            new_value: datetime.date | None = getattr(form, attr)
+            old_value: datetime.datetime | None = getattr(cycle, attr)
+            old_date = old_value.date() if old_value is not None else None
+            if new_value == old_date:
+                continue
+            if new_value is None:
+                # Forward-only: clearing a once-set date is not supported in v1.
+                raise ValueError(f"{attr} cannot be cleared once set. Set a new date instead.")
+
+            # Changing an existing date pairs a withdraw of the prior event
+            # with the new event, per the lifecycle event design. The cache
+            # rule (most recent of kind X) means the cache picks the new
+            # event regardless, but the withdraw row carries the audit signal
+            # that the prior plan no longer applies.
+            if old_value is not None:
+                prior_event_id = (
+                    await self.__data.execute(
+                        sqlmodel.select(via(models.sql.LifecycleEvent.id))
+                        .where(
+                            via(models.sql.LifecycleEvent.cycle_key) == cycle.cycle_key,
+                            via(models.sql.LifecycleEvent.event) == event_type,
+                        )
+                        .order_by(via(models.sql.LifecycleEvent.published).desc())
+                        .limit(1)
+                    )
+                ).scalar_one_or_none()
+                if prior_event_id is not None:
+                    self.__data.add(
+                        models.sql.LifecycleEvent(
+                            project_key=cycle.project_key,
+                            cycle_key=cycle.cycle_key,
+                            version_key=None,
+                            event=models.sql.LifecycleEventType.WITHDRAW,
+                            effective=now,
+                            published=now,
+                            target_event_id=prior_event_id,
+                        )
+                    )
+
+            new_datetime = datetime.datetime.combine(new_value, datetime.time.min, tzinfo=datetime.UTC)
+            setattr(cycle, attr, new_datetime)
+            self.__data.add(
+                models.sql.LifecycleEvent(
+                    project_key=cycle.project_key,
+                    cycle_key=cycle.cycle_key,
+                    version_key=None,
+                    event=event_type,
+                    effective=new_datetime,
+                    published=now,
+                )
+            )
+
+        if cycle.lts != form.lts:
+            cycle.lts = form.lts
+
+        await self.__commit_and_log(str(project_key))
+
     async def edit_policy(
         self,
         project_key: models.safe.ProjectKey,
@@ -193,6 +270,41 @@ class CommitteeMember(CommitteeParticipant):
 
         await self.__commit_and_log(str(project_key))
 
+    async def edit_version_scheme(self, form: shared.projects.EditVersionSchemeForm) -> None:
+        project_key = form.project_key
+        project = await self.__data.project(key=str(project_key), status=models.sql.ProjectStatus.ACTIVE).demand(
+            storage.AccessError(f"Project {project_key} not found")
+        )
+
+        # Validate cycle_match by trying to compile it. Empty becomes None
+        # so we don't store empty strings in nullable columns.
+        cycle_match = form.cycle_match.strip() or None
+        if cycle_match is not None:
+            try:
+                compiled = re.compile(cycle_match)
+            except re.error as exc:
+                raise ValueError(f"Invalid cycle_match regex: {exc}") from exc
+            if compiled.groups < 1:
+                raise ValueError("cycle_match must contain at least one capture group")
+
+        version_pattern = form.version_pattern.strip() or None
+        if version_pattern is not None:
+            try:
+                re.compile(version_pattern)
+            except re.error as exc:
+                raise ValueError(f"Invalid version_pattern regex: {exc}") from exc
+
+        try:
+            version_method = models.sql.VersionMethod(form.version_method)
+        except ValueError as exc:
+            raise ValueError(f"Unsupported version method: {form.version_method}") from exc
+        project.version_method = version_method
+        project.version_pattern = version_pattern
+        project.cycle_match = cycle_match
+        project.branch_template = form.branch_template.strip() or None
+
+        await self.__commit_and_log(str(project_key))
+
     async def edit_vote(self, form: shared.projects.VotePolicyForm) -> None:
         project_key = form.project_key
         project, release_policy = await self.__get_or_create_policy(project_key)
diff --git a/atr/storage/writers/release.py b/atr/storage/writers/release.py
index a5664158..06f23800 100644
--- a/atr/storage/writers/release.py
+++ b/atr/storage/writers/release.py
@@ -22,6 +22,7 @@ import asyncio
 import base64
 import contextlib
 import datetime
+import re
 from typing import TYPE_CHECKING, Any, Final
 
 import aiofiles.os
@@ -657,6 +658,13 @@ class CommitteeParticipant(FoundationCommitter):
         # We have the packaging library as a dependency, but it is Python specific
         if version_key_error := util.version_key_error(str(version)):
             raise storage.AccessError(f'Invalid version name "{version!s}": {version_key_error}', status=400)
+
+        if (project.version_pattern is not None) and (not re.fullmatch(project.version_pattern, str(version))):
+            raise storage.AccessError(
+                f'Version "{version!s}" does not match the project\'s version pattern',
+                status=400,
+            )
+
         try:
             cycle_name = cycles.cycle_name_for_version(project, str(version))
         except ValueError as exc:
diff --git a/tests/unit/test_policy_writer.py b/tests/unit/test_policy_writer.py
new file mode 100644
index 00000000..aaf6c022
--- /dev/null
+++ b/tests/unit/test_policy_writer.py
@@ -0,0 +1,270 @@
+# 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.
+
+import datetime
+import unittest.mock as mock
+from types import SimpleNamespace
+
+import pytest
+
+import atr.models.safe as safe
+import atr.models.sql as sql
+import atr.shared.projects as shared_projects
+import atr.storage as storage
+import atr.storage.writers.policy as policy_writer
+
+
+class _Query:
+    def __init__(self, value):
+        self._value = value
+
+    async def get(self):
+        return self._value
+
+    async def demand(self, error):
+        if self._value is None:
+            raise error
+        return self._value
+
+
+class _MockData:
+    def __init__(self, *, project=None, cycle=None, prior_event_id=None):
+        self._project = project
+        self._cycle = cycle
+        self.added: list[object] = []
+        self.commit = mock.AsyncMock()
+        self.execute = mock.AsyncMock()
+        result = mock.MagicMock()
+        result.scalar_one_or_none.return_value = prior_event_id
+        self.execute.return_value = result
+
+    def add(self, item):
+        self.added.append(item)
+
+    def project(self, **_kwargs):
+        return _Query(self._project)
+
+    def project_cycle(self, **_kwargs):
+        return _Query(self._cycle)
+
+
+def _make_committee_member(data):
+    write = mock.MagicMock()
+    write.authorisation.asf_uid = "alice"
+    write_as = mock.MagicMock()
+    return policy_writer.CommitteeMember(write, write_as, data, "alpha")
+
+
+def _cycle(project_key="example", cycle_key="example-default", eod=None, eos=None, eol=None, lts=False):
+    return SimpleNamespace(
+        project_key=project_key,
+        cycle_key=cycle_key,
+        cycle=cycle_key.removeprefix(f"{project_key}-"),
+        eod=eod,
+        eos=eos,
+        eol=eol,
+        lts=lts,
+    )
+
+
+def _project(
+    key="example", version_method=sql.VersionMethod.SIMPLE, version_pattern=None, cycle_match=None, branch_template=None
+):
+    return SimpleNamespace(
+        key=key,
+        version_method=version_method,
+        version_pattern=version_pattern,
+        cycle_match=cycle_match,
+        branch_template=branch_template,
+    )
+
+
+def _cycle_dates_form(*, project_key="example", cycle_key="example-default", eod=None, eos=None, eol=None, lts=False):
+    return shared_projects.EditCycleDatesForm(
+        variant="edit_cycle_dates",
+        csrf_token="test",
+        project_key=safe.ProjectKey(project_key),
+        cycle_key=cycle_key,
+        eod=eod,
+        eos=eos,
+        eol=eol,
+        lts=lts,
+    )
+
+
+def _version_scheme_form(
+    *,
+    project_key="example",
+    version_method=sql.VersionMethod.SIMPLE,
+    version_pattern="",
+    cycle_match="",
+    branch_template="",
+):
+    return shared_projects.EditVersionSchemeForm(
+        variant="edit_version_scheme",
+        csrf_token="test",
+        project_key=safe.ProjectKey(project_key),
+        version_method=version_method,
+        version_pattern=version_pattern,
+        cycle_match=cycle_match,
+        branch_template=branch_template,
+    )
+
+
+async def test_edit_cycle_dates_writes_event_when_setting_first_eod():
+    cycle = _cycle()
+    data = _MockData(cycle=cycle)
+    writer = _make_committee_member(data)
+    form = _cycle_dates_form(eod=datetime.date(2026, 6, 1))
+
+    await writer.edit_cycle_dates(form)
+
+    events = [e for e in data.added if isinstance(e, sql.LifecycleEvent)]
+    assert len(events) == 1
+    assert events[0].event is sql.LifecycleEventType.EOD
+    assert events[0].cycle_key == "example-default"
+    assert events[0].project_key == "example"
+    assert events[0].effective.date() == datetime.date(2026, 6, 1)
+    assert cycle.eod.date() == datetime.date(2026, 6, 1)
+    data.commit.assert_awaited_once()
+
+
+async def test_edit_cycle_dates_pairs_withdraw_when_changing_existing_date():
+    existing = datetime.datetime(2026, 6, 1, tzinfo=datetime.UTC)
+    cycle = _cycle(eod=existing)
+    data = _MockData(cycle=cycle, prior_event_id=42)
+    writer = _make_committee_member(data)
+    form = _cycle_dates_form(eod=datetime.date(2026, 7, 1))
+
+    await writer.edit_cycle_dates(form)
+
+    events = [e for e in data.added if isinstance(e, sql.LifecycleEvent)]
+    assert len(events) == 2
+    withdraw, new_event = events
+    assert withdraw.event is sql.LifecycleEventType.WITHDRAW
+    assert withdraw.target_event_id == 42
+    assert new_event.event is sql.LifecycleEventType.EOD
+    assert new_event.effective.date() == datetime.date(2026, 7, 1)
+
+
+async def test_edit_cycle_dates_skips_unchanged_date():
+    existing = datetime.datetime(2026, 6, 1, tzinfo=datetime.UTC)
+    cycle = _cycle(eod=existing)
+    data = _MockData(cycle=cycle)
+    writer = _make_committee_member(data)
+    form = _cycle_dates_form(eod=datetime.date(2026, 6, 1))
+
+    await writer.edit_cycle_dates(form)
+
+    events = [e for e in data.added if isinstance(e, sql.LifecycleEvent)]
+    assert events == []
+    data.execute.assert_not_awaited()
+
+
+async def test_edit_cycle_dates_rejects_cycle_belonging_to_other_project():
+    cycle = _cycle(project_key="otherproject")
+    data = _MockData(cycle=cycle)
+    writer = _make_committee_member(data)
+    form = _cycle_dates_form(project_key="example", eod=datetime.date(2026, 6, 1))
+
+    with pytest.raises(storage.AccessError, match="does not belong to project"):
+        await writer.edit_cycle_dates(form)
+
+
+async def test_edit_cycle_dates_raises_when_cycle_missing():
+    data = _MockData(cycle=None)
+    writer = _make_committee_member(data)
+    form = _cycle_dates_form(eod=datetime.date(2026, 6, 1))
+
+    with pytest.raises(storage.AccessError, match="not found"):
+        await writer.edit_cycle_dates(form)
+
+
+async def test_edit_version_scheme_saves_all_fields_normalised():
+    project = _project()
+    data = _MockData(project=project)
+    writer = _make_committee_member(data)
+    form = _version_scheme_form(
+        version_method=sql.VersionMethod.SEMVER,
+        version_pattern=r"^\d+\.\d+\.\d+$",
+        cycle_match=r"^(\d+)\.\d+\.\d+$",
+        branch_template="release-{cycle}",
+    )
+
+    await writer.edit_version_scheme(form)
+
+    assert project.version_method is sql.VersionMethod.SEMVER
+    assert project.version_pattern == r"^\d+\.\d+\.\d+$"
+    assert project.cycle_match == r"^(\d+)\.\d+\.\d+$"
+    assert project.branch_template == "release-{cycle}"
+    data.commit.assert_awaited_once()
+
+
+async def test_edit_version_scheme_normalises_empty_strings_to_none():
+    project = _project(
+        version_pattern=r"^\d+$",
+        cycle_match=r"(\d+)",
+        branch_template="release-{cycle}",
+    )
+    data = _MockData(project=project)
+    writer = _make_committee_member(data)
+    form = _version_scheme_form(version_pattern="", cycle_match="", branch_template="")
+
+    await writer.edit_version_scheme(form)
+
+    assert project.version_pattern is None
+    assert project.cycle_match is None
+    assert project.branch_template is None
+
+
+async def test_edit_version_scheme_rejects_invalid_cycle_match_regex():
+    project = _project()
+    data = _MockData(project=project)
+    writer = _make_committee_member(data)
+    form = _version_scheme_form(cycle_match="(unclosed")
+
+    with pytest.raises(ValueError, match="Invalid cycle_match regex"):
+        await writer.edit_version_scheme(form)
+
+
+async def test_edit_version_scheme_rejects_cycle_match_without_capture_group():
+    project = _project()
+    data = _MockData(project=project)
+    writer = _make_committee_member(data)
+    form = _version_scheme_form(cycle_match=r"^\d+\.\d+\.\d+$")
+
+    with pytest.raises(ValueError, match="at least one capture group"):
+        await writer.edit_version_scheme(form)
+
+
+async def test_edit_version_scheme_rejects_invalid_version_pattern_regex():
+    project = _project()
+    data = _MockData(project=project)
+    writer = _make_committee_member(data)
+    form = _version_scheme_form(version_pattern="(unclosed")
+
+    with pytest.raises(ValueError, match="Invalid version_pattern regex"):
+        await writer.edit_version_scheme(form)
+
+
+async def test_edit_version_scheme_raises_when_project_missing():
+    data = _MockData(project=None)
+    writer = _make_committee_member(data)
+    form = _version_scheme_form()
+
+    with pytest.raises(storage.AccessError, match="not found"):
+        await writer.edit_version_scheme(form)


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