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]