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

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


The following commit(s) were added to refs/heads/main by this push:
     new 0f60f8a  Use a list of strings for workflow paths
0f60f8a is described below

commit 0f60f8ac8a1a97e1928a53cd95ee476d056b14bf
Author: Sean B. Palmer <[email protected]>
AuthorDate: Mon Sep 8 20:34:14 2025 +0100

    Use a list of strings for workflow paths
---
 atr/db/__init__.py                              | 22 +++++-
 atr/db/interaction.py                           | 36 ++++++----
 atr/models/api.py                               |  1 -
 atr/models/sql.py                               | 30 +++++----
 atr/routes/projects.py                          | 78 ++++++++++++++--------
 migrations/versions/0027_2025.09.08_69e565eb.py | 89 +++++++++++++++++++++++++
 6 files changed, 199 insertions(+), 57 deletions(-)

diff --git a/atr/db/__init__.py b/atr/db/__init__.py
index 5754b09..d126e13 100644
--- a/atr/db/__init__.py
+++ b/atr/db/__init__.py
@@ -522,12 +522,16 @@ class Session(sqlalchemy.ext.asyncio.AsyncSession):
         release_checklist: Opt[str] = NOT_SET,
         pause_for_rm: Opt[bool] = NOT_SET,
         github_repository_name: Opt[str] = NOT_SET,
-        github_compose_workflow_path: Opt[str] = NOT_SET,
-        github_vote_workflow_path: Opt[str] = NOT_SET,
-        github_finish_workflow_path: Opt[str] = NOT_SET,
+        github_compose_workflow_path: Opt[list[str]] = NOT_SET,
+        github_vote_workflow_path: Opt[list[str]] = NOT_SET,
+        github_finish_workflow_path: Opt[list[str]] = NOT_SET,
+        github_compose_workflow_path_has: Opt[str] = NOT_SET,
+        github_vote_workflow_path_has: Opt[str] = NOT_SET,
+        github_finish_workflow_path_has: Opt[str] = NOT_SET,
         _project: bool = False,
     ) -> Query[sql.ReleasePolicy]:
         query = sqlmodel.select(sql.ReleasePolicy)
+        via = sql.validate_instrumented_attribute
 
         if is_defined(id):
             query = query.where(sql.ReleasePolicy.id == id)
@@ -545,10 +549,22 @@ class Session(sqlalchemy.ext.asyncio.AsyncSession):
             query = query.where(sql.ReleasePolicy.github_repository_name == 
github_repository_name)
         if is_defined(github_compose_workflow_path):
             query = query.where(sql.ReleasePolicy.github_compose_workflow_path 
== github_compose_workflow_path)
+        if is_defined(github_compose_workflow_path_has):
+            query = query.where(
+                
via(sql.ReleasePolicy.github_compose_workflow_path).contains(github_compose_workflow_path_has)
+            )
         if is_defined(github_vote_workflow_path):
             query = query.where(sql.ReleasePolicy.github_vote_workflow_path == 
github_vote_workflow_path)
+        if is_defined(github_vote_workflow_path_has):
+            query = query.where(
+                
via(sql.ReleasePolicy.github_vote_workflow_path).contains(github_vote_workflow_path_has)
+            )
         if is_defined(github_finish_workflow_path):
             query = query.where(sql.ReleasePolicy.github_finish_workflow_path 
== github_finish_workflow_path)
+        if is_defined(github_finish_workflow_path_has):
+            query = query.where(
+                
via(sql.ReleasePolicy.github_finish_workflow_path).contains(github_finish_workflow_path_has)
+            )
 
         if _project:
             query = query.options(joined_load(sql.ReleasePolicy.project))
diff --git a/atr/db/interaction.py b/atr/db/interaction.py
index 2852439..be3bd28 100644
--- a/atr/db/interaction.py
+++ b/atr/db/interaction.py
@@ -472,18 +472,8 @@ async def _delete_release_data_filesystem(release_dir: 
pathlib.Path, release_nam
 async def _trusted_project(repository: str, workflow_ref: str, phase: 
TrustedProjectPhase) -> sql.Project:
     # Debugging
     log.info(f"GitHub OIDC JWT payload: {repository} {workflow_ref}")
+    repository_name, workflow_path = _trusted_project_checks(repository, 
workflow_ref, phase)
 
-    if not repository.startswith("apache/"):
-        raise InteractionError("Repository must start with 'apache/'")
-    repository_name = repository.removeprefix("apache/")
-    if not workflow_ref.startswith(repository + "/"):
-        raise InteractionError(f"Workflow ref must start with repository, got 
{workflow_ref}")
-    workflow_path_at = workflow_ref.removeprefix(repository + "/")
-    if "@" not in workflow_path_at:
-        raise InteractionError(f"Workflow path must contain '@', got 
{workflow_path_at}")
-    workflow_path = workflow_path_at.rsplit("@", 1)[0]
-    if not workflow_path.startswith(".github/workflows/"):
-        raise InteractionError(f"Workflow path must start with 
'.github/workflows/', got {workflow_path}")
     value_error = ValueError(
         f"Release policy for repository {repository_name} and {phase.value} 
workflow path {workflow_path} not found"
     )
@@ -492,15 +482,18 @@ async def _trusted_project(repository: str, workflow_ref: 
str, phase: TrustedPro
         match phase:
             case TrustedProjectPhase.COMPOSE:
                 policy = await db_data.release_policy(
-                    github_repository_name=repository_name, 
github_compose_workflow_path=workflow_path
+                    github_repository_name=repository_name,
+                    github_compose_workflow_path_has=workflow_path,
                 ).demand(value_error)
             case TrustedProjectPhase.VOTE:
                 policy = await db_data.release_policy(
-                    github_repository_name=repository_name, 
github_vote_workflow_path=workflow_path
+                    github_repository_name=repository_name,
+                    github_vote_workflow_path_has=workflow_path,
                 ).demand(value_error)
             case TrustedProjectPhase.FINISH:
                 policy = await db_data.release_policy(
-                    github_repository_name=repository_name, 
github_finish_workflow_path=workflow_path
+                    github_repository_name=repository_name,
+                    github_finish_workflow_path_has=workflow_path,
                 ).demand(value_error)
         project = await db_data.project(release_policy_id=policy.id).demand(
             InteractionError(f"Project for release policy {policy.id} not 
found")
@@ -512,3 +505,18 @@ async def _trusted_project(repository: str, workflow_ref: 
str, phase: TrustedPro
     log.info(f"Release policy: {policy}")
     log.info(f"Project: {project}")
     return project
+
+
+def _trusted_project_checks(repository: str, workflow_ref: str, phase: 
TrustedProjectPhase) -> tuple[str, str]:
+    if not repository.startswith("apache/"):
+        raise InteractionError("Repository must start with 'apache/'")
+    repository_name = repository.removeprefix("apache/")
+    if not workflow_ref.startswith(repository + "/"):
+        raise InteractionError(f"Workflow ref must start with repository, got 
{workflow_ref}")
+    workflow_path_at = workflow_ref.removeprefix(repository + "/")
+    if "@" not in workflow_path_at:
+        raise InteractionError(f"Workflow path must contain '@', got 
{workflow_path_at}")
+    workflow_path = workflow_path_at.rsplit("@", 1)[0]
+    if not workflow_path.startswith(".github/workflows/"):
+        raise InteractionError(f"Workflow path must start with 
'.github/workflows/', got {workflow_path}")
+    return repository_name, workflow_path
diff --git a/atr/models/api.py b/atr/models/api.py
index adf370b..049225c 100644
--- a/atr/models/api.py
+++ b/atr/models/api.py
@@ -230,7 +230,6 @@ class ProjectsListResults(schema.Strict):
 class PublisherDistributionRecordArgs(schema.Strict):
     publisher: str = schema.Field(..., **example("user"))
     jwt: str = schema.Field(..., 
**example("eyJhbGciOiJIUzI1[...]mMjLiuyu5CSpyHI="))
-    project: str = schema.Field(..., **example("example"))
     version: str = schema.Field(..., **example("0.0.1"))
     platform: sql.DistributionPlatform = schema.Field(..., 
**example(sql.DistributionPlatform.ARTIFACT_HUB))
     distribution_owner_namespace: str | None = schema.Field(default=None, 
**example("example"))
diff --git a/atr/models/sql.py b/atr/models/sql.py
index b178e62..ed655a9 100644
--- a/atr/models/sql.py
+++ b/atr/models/sql.py
@@ -659,22 +659,22 @@ Thanks,
         return policy.github_repository_name
 
     @property
-    def policy_github_compose_workflow_path(self) -> str:
+    def policy_github_compose_workflow_path(self) -> list[str]:
         if (policy := self.release_policy) is None:
-            return ""
-        return policy.github_compose_workflow_path
+            return []
+        return policy.github_compose_workflow_path or []
 
     @property
-    def policy_github_vote_workflow_path(self) -> str:
+    def policy_github_vote_workflow_path(self) -> list[str]:
         if (policy := self.release_policy) is None:
-            return ""
-        return policy.github_vote_workflow_path
+            return []
+        return policy.github_vote_workflow_path or []
 
     @property
-    def policy_github_finish_workflow_path(self) -> str:
+    def policy_github_finish_workflow_path(self) -> list[str]:
         if (policy := self.release_policy) is None:
-            return ""
-        return policy.github_finish_workflow_path
+            return []
+        return policy.github_finish_workflow_path or []
 
 
 # Release: Project ReleasePolicy Revision CheckResult
@@ -981,9 +981,15 @@ class ReleasePolicy(sqlmodel.SQLModel, table=True):
     )
     strict_checking: bool = sqlmodel.Field(default=False)
     github_repository_name: str = sqlmodel.Field(default="")
-    github_compose_workflow_path: str = sqlmodel.Field(default="")
-    github_vote_workflow_path: str = sqlmodel.Field(default="")
-    github_finish_workflow_path: str = sqlmodel.Field(default="")
+    github_compose_workflow_path: list[str] = sqlmodel.Field(
+        default_factory=list, sa_column=sqlalchemy.Column(sqlalchemy.JSON, 
nullable=False)
+    )
+    github_vote_workflow_path: list[str] = sqlmodel.Field(
+        default_factory=list, sa_column=sqlalchemy.Column(sqlalchemy.JSON, 
nullable=False)
+    )
+    github_finish_workflow_path: list[str] = sqlmodel.Field(
+        default_factory=list, sa_column=sqlalchemy.Column(sqlalchemy.JSON, 
nullable=False)
+    )
 
     # 1-1: ReleasePolicy -> Project
     # 1-1: Project -C-> ReleasePolicy
diff --git a/atr/routes/projects.py b/atr/routes/projects.py
index 97336eb..ceb797e 100644
--- a/atr/routes/projects.py
+++ b/atr/routes/projects.py
@@ -82,9 +82,11 @@ class ReleasePolicyForm(forms.Typed):
         "GitHub repository name",
         description="The name of the GitHub repository to use for the release, 
excluding the apache/ prefix.",
     )
-    github_compose_workflow_path = forms.optional(
-        "GitHub compose workflow path",
-        description="The full path to the GitHub workflow to use for the 
release,"
+    github_compose_workflow_path = forms.textarea(
+        "GitHub compose workflow paths",
+        optional=True,
+        rows=5,
+        description="The full paths to the GitHub workflows to use for the 
release,"
         " including the .github/workflows/ prefix.",
     )
     strict_checking = forms.boolean(
@@ -128,9 +130,11 @@ class ReleasePolicyForm(forms.Typed):
         rows=10,
         description="Email template for messages to start a vote on a 
release.",
     )
-    github_vote_workflow_path = forms.optional(
-        "GitHub vote workflow path",
-        description="The full path to the GitHub workflow to use for the 
release,"
+    github_vote_workflow_path = forms.textarea(
+        "GitHub vote workflow paths",
+        optional=True,
+        rows=5,
+        description="The full paths to the GitHub workflows to use for the 
release,"
         " including the .github/workflows/ prefix.",
     )
 
@@ -142,9 +146,11 @@ class ReleasePolicyForm(forms.Typed):
         rows=10,
         description="Email template for messages to announce a finished 
release.",
     )
-    github_finish_workflow_path = forms.optional(
-        "GitHub finish workflow path",
-        description="The full path to the GitHub workflow to use for the 
release,"
+    github_finish_workflow_path = forms.textarea(
+        "GitHub finish workflow paths",
+        optional=True,
+        rows=5,
+        description="The full paths to the GitHub workflows to use for the 
release,"
         " including the .github/workflows/ prefix.",
     )
 
@@ -192,18 +198,30 @@ class ReleasePolicyForm(forms.Typed):
         if grn:
             if "/" in grn:
                 _form_append(self.github_repository_name, "GitHub repository 
name must not contain a slash.")
-            if compose and (not compose.startswith(".github/workflows/")):
-                _form_append(
-                    self.github_compose_workflow_path, "GitHub workflow path 
must start with '.github/workflows/'."
-                )
-            if vote and (not vote.startswith(".github/workflows/")):
-                _form_append(
-                    self.github_vote_workflow_path, "GitHub workflow path must 
start with '.github/workflows/'."
-                )
-            if finish and (not finish.startswith(".github/workflows/")):
-                _form_append(
-                    self.github_finish_workflow_path, "GitHub workflow path 
must start with '.github/workflows/'."
-                )
+            if compose:
+                for p in _parse_artifact_paths(compose):
+                    if not p.startswith(".github/workflows/"):
+                        _form_append(
+                            self.github_compose_workflow_path,
+                            "GitHub workflow paths must start with 
'.github/workflows/'.",
+                        )
+                        break
+            if vote:
+                for p in _parse_artifact_paths(vote):
+                    if not p.startswith(".github/workflows/"):
+                        _form_append(
+                            self.github_vote_workflow_path,
+                            "GitHub workflow paths must start with 
'.github/workflows/'.",
+                        )
+                        break
+            if finish:
+                for p in _parse_artifact_paths(finish):
+                    if not p.startswith(".github/workflows/"):
+                        _form_append(
+                            self.github_finish_workflow_path,
+                            "GitHub workflow paths must start with 
'.github/workflows/'.",
+                        )
+                        break
 
         return not self.errors
 
@@ -502,9 +520,15 @@ async def _policy_edit(
             util.unwrap(policy_form.binary_artifact_paths.data)
         )
         release_policy.github_repository_name = 
util.unwrap(policy_form.github_repository_name.data)
-        release_policy.github_compose_workflow_path = 
util.unwrap(policy_form.github_compose_workflow_path.data)
-        release_policy.github_vote_workflow_path = 
util.unwrap(policy_form.github_vote_workflow_path.data)
-        release_policy.github_finish_workflow_path = 
util.unwrap(policy_form.github_finish_workflow_path.data)
+        release_policy.github_compose_workflow_path = _parse_artifact_paths(
+            util.unwrap(policy_form.github_compose_workflow_path.data)
+        )
+        release_policy.github_vote_workflow_path = _parse_artifact_paths(
+            util.unwrap(policy_form.github_vote_workflow_path.data)
+        )
+        release_policy.github_finish_workflow_path = _parse_artifact_paths(
+            util.unwrap(policy_form.github_finish_workflow_path.data)
+        )
         release_policy.strict_checking = 
util.unwrap(policy_form.strict_checking.data)
 
         # Vote section
@@ -549,9 +573,9 @@ async def _policy_form_create(project: sql.Project) -> 
ReleasePolicyForm:
     policy_form.pause_for_rm.data = project.policy_pause_for_rm
     policy_form.strict_checking.data = project.policy_strict_checking
     policy_form.github_repository_name.data = 
project.policy_github_repository_name
-    policy_form.github_compose_workflow_path.data = 
project.policy_github_compose_workflow_path
-    policy_form.github_vote_workflow_path.data = 
project.policy_github_vote_workflow_path
-    policy_form.github_finish_workflow_path.data = 
project.policy_github_finish_workflow_path
+    policy_form.github_compose_workflow_path.data = 
"\n".join(project.policy_github_compose_workflow_path)
+    policy_form.github_vote_workflow_path.data = 
"\n".join(project.policy_github_vote_workflow_path)
+    policy_form.github_finish_workflow_path.data = 
"\n".join(project.policy_github_finish_workflow_path)
 
     # Set the hashes and value of the current defaults
     policy_form.default_start_vote_template_hash.data = util.compute_sha3_256(
diff --git a/migrations/versions/0027_2025.09.08_69e565eb.py 
b/migrations/versions/0027_2025.09.08_69e565eb.py
new file mode 100644
index 0000000..5c7ca8c
--- /dev/null
+++ b/migrations/versions/0027_2025.09.08_69e565eb.py
@@ -0,0 +1,89 @@
+"""Use a list of strings for release policy workflow URLs
+
+Revision ID: 0027_2025.09.08_69e565eb
+Revises: 0026_2025.09.04_eb02c4d9
+Create Date: 2025-09-08 18:57:18.049164+00:00
+"""
+
+import json
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+# Revision identifiers, used by Alembic
+revision: str = "0027_2025.09.08_69e565eb"
+down_revision: str | None = "0026_2025.09.04_eb02c4d9"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+    bind = op.get_bind()
+    for col in [
+        "github_compose_workflow_path",
+        "github_vote_workflow_path",
+        "github_finish_workflow_path",
+    ]:
+        rows = bind.execute(sa.text(f"SELECT id, {col} AS v FROM 
releasepolicy")).mappings().all()
+        for row in rows:
+            v = row["v"]
+            if not v:
+                new_v = json.dumps([])
+            elif isinstance(v, str):
+                try:
+                    parsed = json.loads(v)
+                    if isinstance(parsed, list):
+                        new_v = v
+                    else:
+                        new_v = json.dumps([v])
+                except Exception:
+                    new_v = json.dumps([v])
+            else:
+                continue
+            bind.execute(
+                sa.text(f"UPDATE releasepolicy SET {col} = :v WHERE id = :id"),
+                {"v": new_v, "id": row["id"]},
+            )
+
+    with op.batch_alter_table("releasepolicy", schema=None) as batch_op:
+        batch_op.alter_column(
+            "github_compose_workflow_path", existing_type=sa.VARCHAR(), 
type_=sa.JSON(), nullable=False
+        )
+        batch_op.alter_column("github_vote_workflow_path", 
existing_type=sa.VARCHAR(), type_=sa.JSON(), nullable=False)
+        batch_op.alter_column(
+            "github_finish_workflow_path", existing_type=sa.VARCHAR(), 
type_=sa.JSON(), nullable=False
+        )
+
+
+def downgrade() -> None:
+    bind = op.get_bind()
+    for col in [
+        "github_finish_workflow_path",
+        "github_vote_workflow_path",
+        "github_compose_workflow_path",
+    ]:
+        rows = bind.execute(sa.text(f"SELECT id, {col} AS v FROM 
releasepolicy")).mappings().all()
+        for row in rows:
+            v = row["v"]
+            new_v = ""
+            if isinstance(v, str):
+                try:
+                    parsed = json.loads(v)
+                    if isinstance(parsed, list) and parsed:
+                        new_v = str(parsed[0])
+                except Exception:
+                    new_v = v
+            bind.execute(
+                sa.text(f"UPDATE releasepolicy SET {col} = :v WHERE id = :id"),
+                {"v": new_v, "id": row["id"]},
+            )
+
+    with op.batch_alter_table("releasepolicy", schema=None) as batch_op:
+        batch_op.alter_column(
+            "github_finish_workflow_path", existing_type=sa.JSON(), 
type_=sa.VARCHAR(), nullable=False
+        )
+        batch_op.alter_column("github_vote_workflow_path", 
existing_type=sa.JSON(), type_=sa.VARCHAR(), nullable=False)
+        batch_op.alter_column(
+            "github_compose_workflow_path", existing_type=sa.JSON(), 
type_=sa.VARCHAR(), nullable=False
+        )


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

Reply via email to