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/29 14:22:09 UTC

(tooling-trusted-releases) 01/01: #139 - Add export of .asf.yaml block to allow seeding

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 ee48309f313017bca6046fb48e312df1d11100d3
Author: Alastair McFarlane <ar...@apache.org>
AuthorDate: Fri May 29 15:21:46 2026 +0100

    #139 - Add export of .asf.yaml block to allow seeding
---
 atr/get/projects.py                           | 176 ++++++++++++++++++++++----
 atr/storage/writers/tokens.py                 |  12 +-
 tests/Dockerfile.e2e                          |   2 +-
 tests/e2e/projects/test_asf_yaml_roundtrip.py | 150 ++++++++++++++++++++++
 tests/unit/test_asf_yaml_export.py            | 134 ++++++++++++++++++++
 5 files changed, 440 insertions(+), 34 deletions(-)

diff --git a/atr/get/projects.py b/atr/get/projects.py
index 16737dab..3598d493 100644
--- a/atr/get/projects.py
+++ b/atr/get/projects.py
@@ -100,6 +100,59 @@ async def add_project(
     )
 
 
+@get.typed
+async def project_yaml(
+    session: web.Committer, _project_yaml: Literal["project/yaml"], project_key: safe.ProjectKey
+) -> web.WerkzeugResponse | str:
+    """
+    URL: /project/yaml/<project_key>
+    """
+    async with db.session() as data:
+        project = await data.project(
+            key=str(project_key),
+            _committee=True,
+            _release_policy=True,
+        ).demand(base.ASFQuartException(f"Project {project_key} not found", errorcode=404))
+
+    is_committee_member = bool(project.committee and user.is_committee_member(project.committee, session.uid))
+    if not (is_committee_member or session.is_admin):
+        raise base.ASFQuartException("You are not a member of this project's committee", errorcode=403)
+
+    yaml_text = _asf_yaml_export(project)
+
+    page = htm.Block()
+    page.p[htm.a(".atr-back-link", href=util.as_url(view, project_key=project.key))["← Back to project"]]
+    page.h1["Export .asf.yaml"]
+    page.p[
+        "This is the ",
+        htm.code[".asf.yaml"],
+        " project block describing what ATR currently holds for ",
+        project.display_name,
+        ". Add it to your repository's ",
+        htm.code[".asf.yaml"],
+        " to keep this metadata in sync from the repository side.",
+    ]
+
+    card = htm.Block(htm.div, classes=".card.mb-4")
+    card.div(".card-header.bg-light.d-flex.justify-content-between.align-items-center")[
+        htm.h3(".mb-0")[".asf.yaml"],
+        htm.button(
+            ".btn.btn-sm.btn-outline-secondary.atr-copy-btn",
+            type="button",
+            data_clipboard_target="#asf-yaml-output",
+        )[htm.i(".bi.bi-clipboard"), " Copy"],
+    ]
+    card.div(".card-body")[htm.pre("#asf-yaml-output.mb-0")[yaml_text]]
+    page.append(card.collect())
+
+    return await template.blank(
+        title="Export .asf.yaml",
+        description=f"The .asf.yaml project block for {project.display_name}.",
+        content=page.collect(),
+        javascripts=["clipboard-copy"],
+    )
+
+
 @get.typed
 async def projects(session: web.Public, _projects: Literal["projects"]) -> str:
     """
@@ -261,6 +314,65 @@ async def view(
     )
 
 
+def _asf_yaml_export(project: sql.Project) -> str:
+    block: dict[str, object] = {"metadata": _asf_yaml_metadata(project)}
+    policy = _asf_yaml_recipient_blocks(project)
+    if policy:
+        block["policy"] = policy
+    # atr_sync defaults to on, so we emit it as a visible reminder the sync exists
+    block["features"] = {"atr_sync": "true"}
+    return strictyaml.as_document({"project": block}).as_yaml()
+
+
+def _asf_yaml_metadata(project: sql.Project) -> dict[str, object]:
+    metadata: dict[str, object] = {}
+    if project.committee_key:
+        metadata["committee"] = project.committee_key
+    if project.name:
+        metadata["name"] = project.name
+    for field in (
+        "description",
+        "short_description",
+        "homepage",
+        "lifecycle_page",
+        "download_page",
+        "bug_database",
+        "mailing_lists",
+    ):
+        value = getattr(project, field)
+        if value:
+            metadata[field] = value
+    if project.repositories:
+        metadata["repositories"] = list(project.repositories)
+    if project.standards:
+        metadata["standards"] = list(project.standards)
+    # categories and programming_languages are stored comma-joined, so split them back out
+    for field, raw in (("categories", project.categories), ("programming_languages", project.programming_languages)):
+        items = [item.strip() for item in (raw or "").split(",") if item.strip()]
+        if items:
+            metadata[field] = items
+    return metadata
+
+
+def _asf_yaml_recipient_blocks(project: sql.Project) -> dict[str, object]:
+    policy: dict[str, object] = {}
+    for key, action in (
+        ("vote_recipients", sql.RecipientAction.VOTE),
+        ("announce_recipients", sql.RecipientAction.ANNOUNCE),
+    ):
+        to, cc, bcc = project.policy_recipients(action)
+        recipients: dict[str, object] = {}
+        if to:
+            recipients["to"] = to
+        if cc:
+            recipients["cc"] = list(cc)
+        if bcc:
+            recipients["bcc"] = list(bcc)
+        if recipients:
+            policy[key] = recipients
+    return policy
+
+
 def _cycle_display_name(cycle: sql.ProjectCycle) -> str:
     # The "default" cycle is what every project gets when it has no cycle_match
     return "No lifecycle information" if cycle.cycle == "default" else f"Version {cycle.cycle}"
@@ -404,6 +516,34 @@ def _input_with_variables(
     return htm.div[elements]
 
 
+def _recipient_grid_widget(project: sql.Project, action: sql.RecipientAction) -> htm.Element:
+    committee = project.committee
+    committee_key = committee.key if (committee is not None) else str(project.key)
+    is_podling = bool(committee is not None and committee.is_podling)
+    options = util.configurable_recipients(action, committee_key, is_podling=is_podling)
+    stored_to, stored_cc, stored_bcc = project.policy_recipients(action)
+    default_to = stored_to if (stored_to in options) else (options[0] if options else None)
+    return htm.div("#email_to")[
+        render.html_recipients_to_radios(options, default_to=default_to),
+        htpy.details(".mt-2")[
+            htpy.summary["Select CC and BCC recipients"],
+            render.html_recipients_cc_bcc_table(options, selected_cc=set(stored_cc), selected_bcc=set(stored_bcc)),
+        ],
+    ]
+
+
+def _recipient_summary(to: str, cc: list[str], bcc: list[str]) -> htm.Element | str:
+    if not to:
+        return "Not set"
+    extras = []
+    if cc:
+        extras.append(f"{len(cc)} CC")
+    if bcc:
+        extras.append(f"{len(bcc)} BCC")
+    suffix = f" (+{', '.join(extras)})" if extras else ""
+    return htm.span[htm.a(href=f"mailto:{to}")[to], suffix]
+
+
 def _release_url(project: sql.Project, release: sql.Release) -> str:
     project_key = str(project.key)
     version_key = str(release.version)
@@ -439,6 +579,14 @@ async def _render_actions_card(
                         )["Create a sibling project"]
                     ]
                 )
+                body.append(
+                    htm.p[
+                        htm.a(
+                            ".btn.btn-sm.btn-outline-secondary",
+                            href=util.as_url(project_yaml, project_key=project.key),
+                        )["Export .asf.yaml"]
+                    ]
+                )
     return card.collect()
 
 
@@ -1108,34 +1256,6 @@ async def _render_vote_form(project: sql.Project) -> htm.Element:
     return card.collect()
 
 
-def _recipient_summary(to: str, cc: list[str], bcc: list[str]) -> htm.Element | str:
-    if not to:
-        return "Not set"
-    extras = []
-    if cc:
-        extras.append(f"{len(cc)} CC")
-    if bcc:
-        extras.append(f"{len(bcc)} BCC")
-    suffix = f" (+{', '.join(extras)})" if extras else ""
-    return htm.span[htm.a(href=f"mailto:{to}")[to], suffix]
-
-
-def _recipient_grid_widget(project: sql.Project, action: sql.RecipientAction) -> htm.Element:
-    committee = project.committee
-    committee_key = committee.key if (committee is not None) else str(project.key)
-    is_podling = bool(committee is not None and committee.is_podling)
-    options = util.configurable_recipients(action, committee_key, is_podling=is_podling)
-    stored_to, stored_cc, stored_bcc = project.policy_recipients(action)
-    default_to = stored_to if (stored_to in options) else (options[0] if options else None)
-    return htm.div("#email_to")[
-        render.html_recipients_to_radios(options, default_to=default_to),
-        htpy.details(".mt-2")[
-            htpy.summary["Select CC and BCC recipients"],
-            render.html_recipients_cc_bcc_table(options, selected_cc=set(stored_cc), selected_bcc=set(stored_bcc)),
-        ],
-    ]
-
-
 def _textarea_with_variables(
     field_name: str,
     default_value: str,
diff --git a/atr/storage/writers/tokens.py b/atr/storage/writers/tokens.py
index 06f07cdd..62f334fd 100644
--- a/atr/storage/writers/tokens.py
+++ b/atr/storage/writers/tokens.py
@@ -24,6 +24,7 @@ import hashlib
 
 import sqlmodel
 
+import atr.config as config
 import atr.db as db
 import atr.jwtoken as jwtoken
 import atr.ldap as ldap
@@ -127,11 +128,12 @@ class FoundationCommitter(GeneralPublic):
             )
             raise storage.AccessError("Authentication failed", status=401)
 
-        # Verify account still exists in LDAP
-        account_details = await ldap.account_lookup(self.__asf_uid)
-        if (account_details is None) or ldap.is_banned(account_details):
-            log.auth_failure("jwt_issuance", "account_deleted_or_banned", self.__asf_uid)
-            raise storage.AccessError("Authentication failed", status=401)
+        # Verify account still exists in LDAP. Test mode doesn't have LDAP wired up so we skip it
+        if not config.is_test_mode():
+            account_details = await ldap.account_lookup(self.__asf_uid)
+            if (account_details is None) or ldap.is_banned(account_details):
+                log.auth_failure("jwt_issuance", "account_deleted_or_banned", self.__asf_uid)
+                raise storage.AccessError("Authentication failed", status=401)
 
         issued_jwt = jwtoken.issue(self.__asf_uid, pat_hash=pat_hash)
         log.auth_event("jwt_issued", self.__asf_uid, pat_hash=pat_hash)
diff --git a/tests/Dockerfile.e2e b/tests/Dockerfile.e2e
index 90bd9b5c..6b6730ef 100644
--- a/tests/Dockerfile.e2e
+++ b/tests/Dockerfile.e2e
@@ -1,6 +1,6 @@
 FROM mcr.microsoft.com/playwright/python:v1.58.0-noble
 
-RUN pip3 install --no-cache-dir --break-system-packages pytest pytest-playwright
+RUN pip3 install --no-cache-dir --break-system-packages pytest pytest-playwright playwright==1.58.0 strictyaml
 
 COPY . /run/tests/e2e
 WORKDIR /run/tests
diff --git a/tests/e2e/projects/test_asf_yaml_roundtrip.py b/tests/e2e/projects/test_asf_yaml_roundtrip.py
new file mode 100644
index 00000000..47e72f92
--- /dev/null
+++ b/tests/e2e/projects/test_asf_yaml_roundtrip.py
@@ -0,0 +1,150 @@
+# 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.
+
+from __future__ import annotations
+
+import json
+import os
+from typing import TYPE_CHECKING, Any, Final
+
+import e2e.helpers as helpers
+import pytest
+import strictyaml
+from playwright.sync_api import expect
+
+if TYPE_CHECKING:
+    from collections.abc import Generator
+
+    from playwright.sync_api import APIRequestContext, Browser, BrowserContext, Page
+
+_BASE_URL: Final[str] = os.environ.get("ATR_BASE_URL", "https://localhost.apache.org:8080")
+
+PROJECT_KEY: Final[str] = "test-asfyaml"
+EXPORT_URL: Final[str] = f"/project/yaml/{PROJECT_KEY}"
+
+# The .asf.yaml project block we import, then expect to get back unchanged on export.
+# It mirrors the inbound schema from infrastructure-asfyaml exactly, so a faithful
+# round-trip should reproduce it byte-for-byte once parsed.
+SOURCE_YAML: Final[str] = """\
+project:
+  metadata:
+    committee: test
+    name: Apache Test Asfyaml
+    short_description: A round-trip fixture project.
+    homepage: https://test.apache.org/
+    lifecycle_page: https://test.apache.org/lifecycle
+    download_page: https://test.apache.org/download
+    bug_database: https://issues.apache.org/jira/browse/TESTASFYAML
+    mailing_lists: https://test.apache.org/mail
+    repositories:
+    - https://github.com/apache/test-asfyaml.git
+    standards:
+    - https://www.rfc-editor.org/rfc/rfc9999
+    categories:
+    - build-management
+    - testing
+    programming_languages:
+    - python
+  policy:
+    vote_recipients:
+      to: private@test.apache.org
+      cc:
+      - dev@test.apache.org
+    announce_recipients:
+      to: announce@test.apache.org
+  features:
+    atr_sync: true
+"""
+
+
+@pytest.fixture
+def export_page(roundtrip_context: BrowserContext) -> Generator[Page]:
+    page = roundtrip_context.new_page()
+    helpers.visit(page, EXPORT_URL)
+    yield page
+    page.close()
+
+
+@pytest.fixture(scope="module")
+def roundtrip_context(browser: Browser) -> Generator[BrowserContext]:
+    context = browser.new_context(ignore_https_errors=True)
+    page = context.new_page()
+    helpers.log_in(page)
+    _import_project(page.request, _mint_pat_and_jwt(page))
+    page.close()
+    yield context
+    cleanup = context.new_page()
+    helpers.log_in(cleanup)
+    _delete_project(cleanup)
+    cleanup.close()
+    context.close()
+
+
+def test_export_round_trips_imported_yaml(export_page: Page) -> None:
+    """Exporting a project reproduces the .asf.yaml block that was imported into it."""
+    exported = export_page.locator("#asf-yaml-output").inner_text()
+    assert strictyaml.load(exported).data == strictyaml.load(SOURCE_YAML).data
+
+
+def _delete_project(page: Page) -> None:
+    # A project with no releases offers a "Delete project" button in its Actions card.
+    helpers.visit(page, f"/projects/{PROJECT_KEY}")
+    button = page.get_by_role("button", name="Delete project")
+    if button.count() == 0:
+        return
+    page.once("dialog", lambda dialog: dialog.accept())
+    button.click()
+    page.wait_for_load_state()
+
+
+def _import_payload() -> dict[str, Any]:
+    # Replays what infrastructure-asfyaml does: committee is authored inside the
+    # metadata block but the API expects it at the top level, so pop it out.
+    block = strictyaml.load(SOURCE_YAML).data["project"]
+    metadata = dict(block["metadata"])
+    committee_key = metadata.pop("committee")
+    payload: dict[str, Any] = {
+        "project_key": PROJECT_KEY,
+        "committee_key": committee_key,
+        "project": metadata,
+    }
+    if "policy" in block:
+        payload["policy"] = block["policy"]
+    return payload
+
+
+def _import_project(request: APIRequestContext, jwt: str) -> None:
+    response = request.post(
+        f"{_BASE_URL}/api/project/config",
+        headers={"Authorization": f"Bearer {jwt}", "Content-Type": "application/json"},
+        data=json.dumps(_import_payload()),
+    )
+    if not response.ok:
+        raise RuntimeError(f"Import via /api/project/config failed ({response.status}): {response.text()}")
+
+
+def _mint_pat_and_jwt(page: Page) -> str:
+    helpers.visit(page, "/tokens")
+    page.locator('input[name="label"]').fill("e2e-asfyaml-roundtrip")
+    page.get_by_role("button", name="Generate token").click()
+    page.wait_for_load_state()
+    pat = page.locator(".flash-success code").first.inner_text().strip()
+
+    page.locator('#issue-jwt-form input[name="pat"]').fill(pat)
+    page.get_by_role("button", name="Generate JWT").click()
+    expect(page.locator("#jwt-container")).to_be_visible()
+    return page.locator("#jwt-output").inner_text().strip()
diff --git a/tests/unit/test_asf_yaml_export.py b/tests/unit/test_asf_yaml_export.py
new file mode 100644
index 00000000..b356c519
--- /dev/null
+++ b/tests/unit/test_asf_yaml_export.py
@@ -0,0 +1,134 @@
+# 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 strictyaml
+
+import atr.get.projects as projects
+import atr.models.sql as sql
+
+
+class _FakeProject:
+    def __init__(self, recipients=None, **fields):
+        defaults = {
+            "committee_key": None,
+            "name": None,
+            "description": None,
+            "short_description": None,
+            "homepage": None,
+            "lifecycle_page": None,
+            "download_page": None,
+            "bug_database": None,
+            "mailing_lists": None,
+            "repositories": [],
+            "standards": [],
+            "categories": None,
+            "programming_languages": None,
+        }
+        defaults.update(fields)
+        for key, value in defaults.items():
+            setattr(self, key, value)
+        self._recipients = recipients or {}
+
+    def policy_recipients(self, action):
+        entry = self._recipients.get(action, {})
+        return entry.get("to", ""), list(entry.get("cc", [])), list(entry.get("bcc", []))
+
+
+def test_export_includes_only_recipient_keys_that_are_set() -> None:
+    project = _FakeProject(
+        committee_key="tooling",
+        name="Apache Example",
+        recipients={sql.RecipientAction.VOTE: {"to": "private@example.apache.org"}},
+    )
+
+    policy = strictyaml.load(projects._asf_yaml_export(project)).data["project"]["policy"]
+
+    assert policy == {"vote_recipients": {"to": "private@example.apache.org"}}
+
+
+def test_export_minimal_project_omits_empty_blocks() -> None:
+    project = _FakeProject(committee_key="tooling", name="Apache Trusted Releases")
+
+    loaded = strictyaml.load(projects._asf_yaml_export(project)).data
+
+    assert loaded == {
+        "project": {
+            "metadata": {"committee": "tooling", "name": "Apache Trusted Releases"},
+            "features": {"atr_sync": "true"},
+        }
+    }
+
+
+def test_export_reproduces_every_field() -> None:
+    project = _FakeProject(
+        committee_key="tooling",
+        name="Apache Trusted Releases",
+        short_description="A platform for making official ASF software releases.",
+        homepage="https://tooling.apache.org/trusted-releases.html",
+        lifecycle_page="https://tooling.apache.org/trusted-releases.html",
+        download_page="https://github.com/apache/tooling-trusted-releases",
+        bug_database="https://github.com/apache/tooling-trusted-releases/issues",
+        mailing_lists="https://tooling.apache.org/volunteer.html",
+        repositories=["git+ssh://git@github.com:apache/tooling-trusted-releases.git"],
+        standards=["https://owasp.org/www-project-application-security-verification-standard/"],
+        categories="build-management",
+        programming_languages="python",
+        recipients={
+            sql.RecipientAction.VOTE: {"to": "private@tooling.apache.org", "cc": ["dev@tooling.apache.org"]},
+            sql.RecipientAction.ANNOUNCE: {"to": "announce@apache.org"},
+        },
+    )
+
+    loaded = strictyaml.load(projects._asf_yaml_export(project)).data
+
+    assert loaded == {
+        "project": {
+            "metadata": {
+                "committee": "tooling",
+                "name": "Apache Trusted Releases",
+                "short_description": "A platform for making official ASF software releases.",
+                "homepage": "https://tooling.apache.org/trusted-releases.html",
+                "lifecycle_page": "https://tooling.apache.org/trusted-releases.html",
+                "download_page": "https://github.com/apache/tooling-trusted-releases",
+                "bug_database": "https://github.com/apache/tooling-trusted-releases/issues",
+                "mailing_lists": "https://tooling.apache.org/volunteer.html",
+                "repositories": ["git+ssh://git@github.com:apache/tooling-trusted-releases.git"],
+                "standards": ["https://owasp.org/www-project-application-security-verification-standard/"],
+                "categories": ["build-management"],
+                "programming_languages": ["python"],
+            },
+            "policy": {
+                "vote_recipients": {"to": "private@tooling.apache.org", "cc": ["dev@tooling.apache.org"]},
+                "announce_recipients": {"to": "announce@apache.org"},
+            },
+            "features": {"atr_sync": "true"},
+        }
+    }
+
+
+def test_export_splits_comma_joined_columns_into_sequences() -> None:
+    project = _FakeProject(
+        committee_key="tooling",
+        name="Apache Example",
+        categories="data, storage",
+        programming_languages="c,python",
+    )
+
+    metadata = strictyaml.load(projects._asf_yaml_export(project)).data["project"]["metadata"]
+
+    assert metadata["categories"] == ["data", "storage"]
+    assert metadata["programming_languages"] == ["c", "python"]


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