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

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


The following commit(s) were added to refs/heads/sbp by this push:
     new 02d2bb3f Add an API endpoint to update release policies
02d2bb3f is described below

commit 02d2bb3ffcbe0d3c30da1ec2cc9d2a1bebc93599
Author: Sean B. Palmer <[email protected]>
AuthorDate: Fri Mar 20 19:55:19 2026 +0000

    Add an API endpoint to update release policies
---
 atr/api/__init__.py           |  27 ++++++++++
 atr/models/api.py             |  32 +++++++++++
 atr/storage/writers/policy.py | 121 +++++++++++++++++++++++++++++++++++++++---
 3 files changed, 173 insertions(+), 7 deletions(-)

diff --git a/atr/api/__init__.py b/atr/api/__init__.py
index 7fcabc04..71a91c6d 100644
--- a/atr/api/__init__.py
+++ b/atr/api/__init__.py
@@ -730,6 +730,33 @@ async def policy_get(
     ).model_dump(mode="json"), 200
 
 
[email protected]
[email protected]
+@quart_schema.security_scheme([{"BearerAuth": []}])
+@quart_schema.validate_response(models.api.PolicyUpdateResults, 200)
+async def policy_update(
+    _policy_update: Literal["policy/update"],
+    data: models.api.PolicyUpdateArgs,
+) -> DictResponse:
+    """
+    URL: POST /policy/update
+
+    Update release policy fields for a project.
+
+    Only fields present in the request body are modified.
+    """
+    asf_uid = _jwt_asf_uid()
+    try:
+        async with storage.write_as_project_committee_member(data.project, 
asf_uid) as wacm:
+            await wacm.policy.edit_policy(data.project, data)
+    except (storage.AccessError, ValueError) as e:
+        raise exceptions.BadRequest(str(e))
+    return models.api.PolicyUpdateResults(
+        endpoint="/policy/update",
+        success=True,
+    ).model_dump(mode="json"), 200
+
+
 @api.typed
 @quart_schema.validate_response(models.api.ProjectGetResults, 200)
 async def project_get(
diff --git a/atr/models/api.py b/atr/models/api.py
index 9b4fc74b..75c0f710 100644
--- a/atr/models/api.py
+++ b/atr/models/api.py
@@ -312,6 +312,38 @@ class PolicyGetResults(schema.Strict):
     policy_vote_comment_template: str
 
 
+class PolicyUpdateArgs(schema.Strict):
+    project: safe.ProjectKey = schema.example("example")
+    announce_release_subject: str | None = None
+    announce_release_template: str | None = None
+    binary_artifact_paths: list[str] | None = None
+    file_tag_mappings: dict[str, list[str]] | None = None
+    github_compose_workflow_path: list[str] | None = None
+    github_finish_workflow_path: list[str] | None = None
+    github_repository_branch: str | None = None
+    github_repository_name: str | None = None
+    github_vote_workflow_path: list[str] | None = None
+    license_check_mode: sql.LicenseCheckMode | None = None
+    mailto_addresses: list[str] | None = None
+    manual_vote: bool | None = None
+    min_hours: int | None = None
+    pause_for_rm: bool | None = None
+    preserve_download_files: bool | None = None
+    release_checklist: str | None = None
+    source_artifact_paths: list[str] | None = None
+    source_excludes_lightweight: list[str] | None = None
+    source_excludes_rat: list[str] | None = None
+    start_vote_subject: str | None = None
+    start_vote_template: str | None = None
+    strict_checking: bool | None = None
+    vote_comment_template: str | None = None
+
+
+class PolicyUpdateResults(schema.Strict):
+    endpoint: Literal["/policy/update"] = schema.alias("endpoint")
+    success: Literal[True] = schema.example(True)
+
+
 class ProjectReleasesResults(schema.Strict):
     endpoint: Literal["/project/releases"] = schema.alias("endpoint")
     releases: Sequence[sql.Release]
diff --git a/atr/storage/writers/policy.py b/atr/storage/writers/policy.py
index d9f2626b..67db558a 100644
--- a/atr/storage/writers/policy.py
+++ b/atr/storage/writers/policy.py
@@ -18,7 +18,7 @@
 # Removing this will cause circular imports
 from __future__ import annotations
 
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, Any, Final
 
 import strictyaml
 import strictyaml.ruamel.error as error
@@ -31,6 +31,21 @@ import atr.storage as storage
 if TYPE_CHECKING:
     import atr.shared as shared
 
+_NULLABLE_POLICY_FIELDS: Final = frozenset({"min_hours"})
+_TRUSTED_PUBLISHING_PATH_FIELDS: Final = frozenset(
+    {
+        "github_compose_workflow_path",
+        "github_vote_workflow_path",
+        "github_finish_workflow_path",
+    }
+)
+_TRUSTED_PUBLISHING_FIELDS: Final = _TRUSTED_PUBLISHING_PATH_FIELDS | 
frozenset(
+    {
+        "github_repository_branch",
+        "github_repository_name",
+    }
+)
+
 
 class GeneralPublic:
     def __init__(
@@ -112,12 +127,7 @@ class CommitteeMember(CommitteeParticipant):
         if not isinstance(atr_tags_data, dict):
             raise ValueError("Invalid file tag mappings")
         atr_tags_dict: dict[str, list[str]] = atr_tags_data
-        for key, values in atr_tags_dict.items():
-            if ".." in key:
-                raise ValueError("File tag mapping keys may not contain '..'")
-            for value in values:
-                if ".." in value:
-                    raise ValueError("File tag mapping values may not contain 
'..'")
+        _validate_file_tag_mappings(atr_tags_dict)
         release_policy.license_check_mode = form.license_check_mode  # 
pyright: ignore[reportAttributeAccessIssue]
         release_policy.source_excludes_lightweight = 
_split_lines_verbatim(form.source_excludes_lightweight)
         release_policy.source_excludes_rat = 
_split_lines_verbatim(form.source_excludes_rat)
@@ -126,6 +136,40 @@ class CommitteeMember(CommitteeParticipant):
 
         await self.__commit_and_log(str(project_key))
 
+    async def edit_policy(
+        self,
+        project_key: models.safe.ProjectKey,
+        update: models.api.PolicyUpdateArgs,
+    ) -> None:
+        # TODO: Ideally we would centralise the validation in this method
+        project, release_policy = await 
self.__get_or_create_policy(project_key)
+        fields_to_update = update.model_fields_set - {"project"}
+        normalised_values: dict[str, Any] = {}
+
+        for field in fields_to_update:
+            value = getattr(update, field)
+            if (value is None) and (field not in _NULLABLE_POLICY_FIELDS):
+                raise ValueError(f"Field '{field}' does not accept null")
+            normalised_values[field] = value
+
+        if ("file_tag_mappings" in fields_to_update) and 
(update.file_tag_mappings is not None):
+            _validate_file_tag_mappings(update.file_tag_mappings)
+
+        if ("min_hours" in fields_to_update) and (update.min_hours is not 
None):
+            _validate_min_hours(update.min_hours)
+
+        if ("manual_vote" in fields_to_update) and update.manual_vote:
+            if project.committee and project.committee.is_podling:
+                raise storage.AccessError("Manual voting is not allowed for 
podlings.")
+
+        if fields_to_update & _TRUSTED_PUBLISHING_FIELDS:
+            
normalised_values.update(_normalise_trusted_publishing_update(release_policy, 
normalised_values))
+
+        for field in fields_to_update:
+            setattr(release_policy, field, normalised_values[field])
+
+        await self.__commit_and_log(str(project_key))
+
     async def edit_finish(self, form: shared.projects.FinishPolicyForm) -> 
None:
         project_key = form.project_key
         project, release_policy = await 
self.__get_or_create_policy(project_key)
@@ -267,6 +311,55 @@ class CommitteeMember(CommitteeParticipant):
             release_policy.start_vote_template = submitted_template
 
 
+def _normalise_text_list(values: list[str]) -> list[str]:
+    return [value.strip() for value in values if value.strip()]
+
+
+def _normalise_text_value(value: str) -> str:
+    return value.strip()
+
+
+def _normalise_trusted_publishing_update(
+    release_policy: models.sql.ReleasePolicy,
+    values: dict[str, Any],
+) -> dict[str, Any]:
+    # TODO: Ideally we would use this function in the form validation too
+    normalised_values: dict[str, Any] = {}
+
+    github_repository_name = release_policy.github_repository_name
+    if "github_repository_name" in values:
+        github_repository_name = 
_normalise_text_value(values["github_repository_name"])
+        normalised_values["github_repository_name"] = github_repository_name
+
+    github_repository_branch = release_policy.github_repository_branch
+    if "github_repository_branch" in values:
+        github_repository_branch = 
_normalise_text_value(values["github_repository_branch"])
+        normalised_values["github_repository_branch"] = 
github_repository_branch
+
+    all_paths: list[str] = []
+    for field in sorted(_TRUSTED_PUBLISHING_PATH_FIELDS):
+        paths = getattr(release_policy, field)
+        if field in values:
+            paths = _normalise_text_list(values[field])
+            normalised_values[field] = paths
+        all_paths.extend(paths)
+
+    if all_paths and (not github_repository_name):
+        raise ValueError("GitHub repository name is required when any workflow 
path is set.")
+
+    if github_repository_branch and (not github_repository_name):
+        raise ValueError("GitHub repository name is required when a GitHub 
branch is set.")
+
+    if github_repository_name and ("/" in github_repository_name):
+        raise ValueError("GitHub repository name must not contain a slash.")
+
+    for path in all_paths:
+        if not path.startswith(".github/workflows/"):
+            raise ValueError("GitHub workflow paths must start with 
'.github/workflows/'.")
+
+    return normalised_values
+
+
 def _split_lines(text: str) -> list[str]:
     return [line.strip() for line in text.split("\n") if line.strip()]
 
@@ -274,3 +367,17 @@ def _split_lines(text: str) -> list[str]:
 def _split_lines_verbatim(text: str) -> list[str]:
     # This still excludes empty lines
     return [line for line in text.split("\n") if line]
+
+
+def _validate_file_tag_mappings(mappings: dict[str, list[str]]) -> None:
+    for key, values in mappings.items():
+        if ".." in key:
+            raise ValueError("File tag mapping keys may not contain '..'")
+        for value in values:
+            if ".." in value:
+                raise ValueError("File tag mapping values may not contain 
'..'")
+
+
+def _validate_min_hours(min_hours: int) -> None:
+    if (min_hours != 0) and ((min_hours < 72) or (min_hours > 144)):
+        raise ValueError("Minimum voting period must be 0 or between 72 and 
144 hours inclusive.")


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to