You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tooling.apache.org by sb...@apache.org on 2025/03/20 15:18:32 UTC

(tooling-trusted-release) branch main updated: Add a page giving instructions to upload using rsync

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 a0b0c2c  Add a page giving instructions to upload using rsync
a0b0c2c is described below

commit a0b0c2cc6e2da57a0f88f6b21be9508265a9ba6a
Author: Sean B. Palmer <se...@miscoranda.com>
AuthorDate: Thu Mar 20 17:18:11 2025 +0200

    Add a page giving instructions to upload using rsync
---
 atr/blueprints/admin/__init__.py                 |   4 +-
 atr/blueprints/admin/admin.py                    |   6 +-
 atr/blueprints/admin/templates/data-browser.html |   2 +-
 atr/db/__init__.py                               |  13 +-
 atr/db/models.py                                 |  64 ++++++++-
 atr/db/service.py                                |  12 +-
 atr/mail.py                                      |   4 +-
 atr/routes/__init__.py                           |   2 +-
 atr/routes/candidate.py                          |  18 ++-
 atr/routes/dev.py                                |   4 +-
 atr/routes/docs.py                               |   4 +-
 atr/routes/download.py                           |  18 +--
 atr/routes/files.py                              | 159 +++++++++++++++++++++++
 atr/routes/keys.py                               |   7 +-
 atr/routes/package.py                            |  68 +++++-----
 atr/routes/projects.py                           |   4 +-
 atr/routes/release.py                            |  34 +++--
 atr/routes/vote_policy.py                        |   4 +-
 atr/server.py                                    |  12 +-
 atr/static/css/bootstrap.custom.css              |   4 +
 atr/tasks/bulk.py                                |  14 +-
 atr/tasks/vote.py                                |  16 +--
 atr/templates/candidate-review.html              |  16 +--
 atr/templates/files-add.html                     | 129 ++++++++++++++++++
 atr/templates/includes/sidebar.html              |   4 +
 atr/templates/package-add.html                   |  21 +--
 atr/templates/package-check.html                 |   6 +-
 atr/templates/project-directory.html             |   2 +-
 atr/templates/release-vote.html                  |   2 +-
 bootstrap/custom.scss                            |   5 +
 30 files changed, 510 insertions(+), 148 deletions(-)

diff --git a/atr/blueprints/admin/__init__.py b/atr/blueprints/admin/__init__.py
index 6b4371c..ae91fdc 100644
--- a/atr/blueprints/admin/__init__.py
+++ b/atr/blueprints/admin/__init__.py
@@ -19,11 +19,11 @@
 
 from typing import Final
 
+import quart
+
 import asfquart.auth as auth
 import asfquart.base as base
 import asfquart.session as session
-import quart
-
 import atr.util as util
 
 BLUEPRINT: Final = quart.Blueprint("admin", __name__, url_prefix="/admin", template_folder="templates")
diff --git a/atr/blueprints/admin/admin.py b/atr/blueprints/admin/admin.py
index 120a6bd..61ce3ab 100644
--- a/atr/blueprints/admin/admin.py
+++ b/atr/blueprints/admin/admin.py
@@ -22,12 +22,12 @@ from collections.abc import Callable, Mapping
 from typing import Any
 
 import aiofiles.os
-import asfquart.base as base
-import asfquart.session as session
 import httpx
 import quart
 import werkzeug.wrappers.response as response
 
+import asfquart.base as base
+import asfquart.session as session
 import atr.blueprints.admin as admin
 import atr.datasources.apache as apache
 import atr.db as db
@@ -149,7 +149,7 @@ async def admin_data(model: str = "Committee") -> str:
                 # Fallback for models without dict() method
                 record_dict = {
                     "id": getattr(record, "id", None),
-                    "storage_key": getattr(record, "storage_key", None),
+                    "name": getattr(record, "name", None),
                 }
                 for key in record.__dict__:
                     if not key.startswith("_"):
diff --git a/atr/blueprints/admin/templates/data-browser.html b/atr/blueprints/admin/templates/data-browser.html
index 0932431..eca4a86 100644
--- a/atr/blueprints/admin/templates/data-browser.html
+++ b/atr/blueprints/admin/templates/data-browser.html
@@ -80,7 +80,7 @@
       {% for record in records %}
         <div class="record">
           <div class="record-header">
-            <h3>{{ record.get('id', record.get('storage_key', 'Unknown ID') ) }}</h3>
+            <h3>{{ record.get("id", record.get("name", "Unknown ID")) }}</h3>
             <span class="record-meta">{{ model }}</span>
           </div>
           <pre>{{ record | tojson(indent=2) }}</pre>
diff --git a/atr/db/__init__.py b/atr/db/__init__.py
index b3478b1..cf3f39f 100644
--- a/atr/db/__init__.py
+++ b/atr/db/__init__.py
@@ -38,7 +38,6 @@ if TYPE_CHECKING:
     from datetime import datetime
 
     import asfquart.base as base
-
     from atr.db.models import ReleasePhase, ReleaseStage, TaskStatus, VoteEntry
 
 _LOGGER: Final = logging.getLogger(__name__)
@@ -168,7 +167,7 @@ class Session(sqlalchemy.ext.asyncio.AsyncSession):
         signature_sha3: Opt[str] = NotSet,
         uploaded: Opt[bool] = NotSet,
         bytes_size: Opt[int] = NotSet,
-        release_key: Opt[str] = NotSet,
+        release_name: Opt[str] = NotSet,
         _release: bool = False,
         _tasks: bool = False,
         _release_project: bool = False,
@@ -190,8 +189,8 @@ class Session(sqlalchemy.ext.asyncio.AsyncSession):
             query = query.where(models.Package.uploaded == uploaded)
         if is_defined(bytes_size):
             query = query.where(models.Package.bytes_size == bytes_size)
-        if is_defined(release_key):
-            query = query.where(models.Package.release_key == release_key)
+        if is_defined(release_name):
+            query = query.where(models.Package.release_name == release_name)
         if _release:
             query = query.options(select_in_load(models.Package.release))
         if _tasks:
@@ -284,7 +283,7 @@ class Session(sqlalchemy.ext.asyncio.AsyncSession):
 
     def release(
         self,
-        storage_key: Opt[str] = NotSet,
+        name: Opt[str] = NotSet,
         stage: Opt[ReleaseStage] = NotSet,
         phase: Opt[ReleasePhase] = NotSet,
         created: Opt[datetime] = NotSet,
@@ -302,8 +301,8 @@ class Session(sqlalchemy.ext.asyncio.AsyncSession):
     ) -> Query[models.Release]:
         query = sqlmodel.select(models.Release)
 
-        if is_defined(storage_key):
-            query = query.where(models.Release.storage_key == storage_key)
+        if is_defined(name):
+            query = query.where(models.Release.name == name)
         if is_defined(stage):
             query = query.where(models.Release.stage == stage)
         if is_defined(phase):
diff --git a/atr/db/models.py b/atr/db/models.py
index df2ff89..9845966 100644
--- a/atr/db/models.py
+++ b/atr/db/models.py
@@ -17,6 +17,7 @@
 
 """The data models to be persisted in the database."""
 
+# NOTE: We can't use symbolic annotations here because sqlmodel doesn't support them
 # from __future__ import annotations
 
 import datetime
@@ -25,8 +26,11 @@ from typing import Any
 
 import pydantic
 import sqlalchemy
+import sqlalchemy.event as event
 import sqlmodel
 
+import atr.db as db
+
 
 class UserRole(str, enum.Enum):
     COMMITTEE_MEMBER = "committee_member"
@@ -117,6 +121,7 @@ class Committee(sqlmodel.SQLModel, table=True):
 class Project(sqlmodel.SQLModel, table=True):
     id: int = sqlmodel.Field(default=None, primary_key=True)
     name: str = sqlmodel.Field(unique=True)
+    # TODO: Ideally full_name would be unique for str only, but that's complex
     full_name: str | None = sqlmodel.Field(default=None)
 
     # True if this a podling project
@@ -143,6 +148,30 @@ class Project(sqlmodel.SQLModel, table=True):
         name = self.name if self.full_name is None else self.full_name
         return f"{name} (podling)" if self.is_podling else name
 
+    @property
+    async def editable_releases(self) -> list["Release"]:
+        """Get the editable ongoing releases for the project."""
+        # TODO: Improve our interface to use in_ automatically for lists
+        editable_phases = [
+            ReleasePhase.RELEASE_CANDIDATE,
+            ReleasePhase.EVALUATE_CLAIMS,
+            ReleasePhase.RELEASE,
+        ]
+        query = (
+            sqlmodel.select(Release)
+            .where(
+                Release.project_id == self.id,
+                db.validate_instrumented_attribute(Release.phase).in_(editable_phases),
+            )
+            .order_by(db.validate_instrumented_attribute(Release.created).desc())
+        )
+
+        async with db.session() as data:
+            results = []
+            for release in (await data.execute(query)).all():
+                results.append(release[0])
+            return results
+
 
 class DistributionChannel(sqlmodel.SQLModel, table=True):
     id: int = sqlmodel.Field(default=None, primary_key=True)
@@ -175,7 +204,7 @@ class Package(sqlmodel.SQLModel, table=True):
     bytes_size: int
 
     # Many-to-one: A package belongs to one release
-    release_key: str = sqlmodel.Field(foreign_key="release.storage_key")
+    release_name: str = sqlmodel.Field(foreign_key="release.name")
     release: "Release" = sqlmodel.Relationship(back_populates="packages")
 
     # One-to-many: A package can have multiple tasks
@@ -201,17 +230,35 @@ class ReleaseStage(str, enum.Enum):
 
 
 class ReleasePhase(str, enum.Enum):
+    # [CANDIDATE]
+    # Step 1: The RC is received from external sources
     RELEASE_CANDIDATE = "release_candidate"
+    # Step 2: The ATR website evaluates claims about the RC
     EVALUATE_CLAIMS = "evaluate_claims"
+    # Step 3: The RC is distributed to project members for testing
     DISTRIBUTE_TEST = "distribute_test"
+    # Step 4: The project members are voting on the RC
     VOTE = "vote"
+    # Step 5: The project vote on the RC has passed
     PASSES = "passes"
+
+    # [CURRENT]
+    # Step 1: The release files are being put in place
     RELEASE = "release"
+    # Step 2: The release files are available but not yet announced
     DISTRIBUTE = "distribute"
+    # Step 3: The release has been announced but not yet released[?]
+    # TODO: Need to check the meaning of this phase
     ANNOUNCE_RELEASE = "announce_release"
+    # Step 4: The release has been announced
     RELEASED = "released"
+
+    # [Other]
+    # An existing release is being imported from ASF SVN dist
     MIGRATION = "migration"
+    # A release candidate has failed at any CANDIDATE stage
     FAILED = "failed"
+    # A previously CURRENT release has been archived
     ARCHIVED = "archived"
 
 
@@ -268,7 +315,9 @@ class Task(sqlmodel.SQLModel, table=True):
 
 
 class Release(sqlmodel.SQLModel, table=True):
-    storage_key: str = sqlmodel.Field(primary_key=True)
+    # We guarantee that "{project.name}-{version}" is unique
+    # Therefore we can use that for the name
+    name: str = sqlmodel.Field(primary_key=True, unique=True)
     stage: ReleaseStage
     phase: ReleasePhase
     created: datetime.datetime
@@ -293,6 +342,11 @@ class Release(sqlmodel.SQLModel, table=True):
 
     votes: list[VoteEntry] = sqlmodel.Field(default_factory=list, sa_column=sqlalchemy.Column(sqlalchemy.JSON))
 
+    # The combination of project_id and version must be unique
+    # Technically we want (project.name, version) to be unique
+    # But project.name is already unique, so project_id works as a proxy thereof
+    __table_args__ = (sqlalchemy.UniqueConstraint("project_id", "version", name="unique_project_version"),)
+
     @property
     def committee(self) -> Committee | None:
         """Get the committee for the release."""
@@ -302,6 +356,12 @@ class Release(sqlmodel.SQLModel, table=True):
         return project.committee
 
 
+@event.listens_for(Release, "before_insert")
+def check_release_name(_mapper: sqlalchemy.orm.Mapper, _connection: sqlalchemy.Connection, release: Release) -> None:
+    if release.name != f"{release.project.name}-{release.version}":
+        raise ValueError(f"Release name must be set to {release.project.name}-{release.version}")
+
+
 class SSHKey(sqlmodel.SQLModel, table=True):
     fingerprint: str = sqlmodel.Field(primary_key=True)
     key: str
diff --git a/atr/db/service.py b/atr/db/service.py
index e97eb06..61f9d22 100644
--- a/atr/db/service.py
+++ b/atr/db/service.py
@@ -45,26 +45,26 @@ async def get_committees(session: sqlalchemy.ext.asyncio.AsyncSession | None = N
         return committees
 
 
-async def get_release_by_key(storage_key: str) -> models.Release | None:
-    """Get a release by its storage key."""
+async def get_release_by_name(name: str) -> models.Release | None:
+    """Get a release by its name."""
     async with db.create_async_db_session() as db_session:
         # Get the release
         query = (
             sqlmodel.select(models.Release)
-            .where(models.Release.storage_key == storage_key)
+            .where(models.Release.name == name)
             .options(db.select_in_load_nested(models.Release.project, models.Project.committee))
         )
         result = await db_session.execute(query)
         return result.scalar_one_or_none()
 
 
-def get_release_by_key_sync(storage_key: str) -> models.Release | None:
-    """Synchronous version of get_release_by_key for use in background tasks."""
+def get_release_by_name_sync(name: str) -> models.Release | None:
+    """Synchronous version of get_release_by_name for use in background tasks."""
     with db.create_sync_db_session() as session:
         # Get the release
         query = (
             sqlmodel.select(models.Release)
-            .where(models.Release.storage_key == storage_key)
+            .where(models.Release.name == name)
             .options(db.select_in_load_nested(models.Release.project, models.Project.committee))
         )
         result = session.execute(query)
diff --git a/atr/mail.py b/atr/mail.py
index 371f831..2f78bb0 100644
--- a/atr/mail.py
+++ b/atr/mail.py
@@ -66,9 +66,9 @@ class VoteEvent:
     """Data class to represent a release vote event."""
 
     def __init__(
-        self, release_key: str, email_recipient: str, subject: str, body: str, vote_end: datetime.datetime
+        self, release_name: str, email_recipient: str, subject: str, body: str, vote_end: datetime.datetime
     ) -> None:
-        self.release_key = release_key
+        self.release_name = release_name
         self.email_recipient = email_recipient
         self.subject = subject
         self.body = body
diff --git a/atr/routes/__init__.py b/atr/routes/__init__.py
index 603fe89..849f78f 100644
--- a/atr/routes/__init__.py
+++ b/atr/routes/__init__.py
@@ -25,10 +25,10 @@ from typing import Any, ParamSpec, TypeVar
 
 import aiofiles
 import aiofiles.os
-import asfquart
 import quart
 import werkzeug.datastructures as datastructures
 
+import asfquart
 import atr.db.models as models
 
 if asfquart.APP is ...:
diff --git a/atr/routes/candidate.py b/atr/routes/candidate.py
index 49fb027..90dd49d 100644
--- a/atr/routes/candidate.py
+++ b/atr/routes/candidate.py
@@ -18,16 +18,15 @@
 """candidate.py"""
 
 import datetime
-import secrets
 
-import asfquart
-import asfquart.auth as auth
-import asfquart.base as base
-import asfquart.session as session
 import quart
 import werkzeug.wrappers.response as response
 import wtforms
 
+import asfquart
+import asfquart.auth as auth
+import asfquart.base as base
+import asfquart.session as session
 import atr.db as db
 import atr.db.models as models
 import atr.routes as routes
@@ -109,9 +108,7 @@ async def release_add_post(session: session.ClientSession, request: quart.Reques
                     errorcode=403,
                 )
 
-            # Generate a 128-bit random token for the release storage key
-            # TODO: Perhaps we should call this the release_key instead
-            storage_key = secrets.token_hex(16)
+            release_name = f"{project_name}-{version}"
             project = await data.project(name=project_name).get()
             if not project:
                 # Create a new project record
@@ -125,17 +122,18 @@ async def release_add_post(session: session.ClientSession, request: quart.Reques
 
             # Create release record with project
             release = models.Release(
-                storage_key=storage_key,
+                name=release_name,
                 stage=models.ReleaseStage.CANDIDATE,
                 phase=models.ReleasePhase.RELEASE_CANDIDATE,
                 project_id=project.id,
+                project=project,
                 version=version,
                 created=datetime.datetime.now(datetime.UTC),
             )
             data.add(release)
 
     # Redirect to the add package page with the storage token
-    return quart.redirect(quart.url_for("root_package_add", storage_key=storage_key))
+    return quart.redirect(quart.url_for("root_package_add", name=release_name))
 
 
 # Root functions
diff --git a/atr/routes/dev.py b/atr/routes/dev.py
index 597507b..765370d 100644
--- a/atr/routes/dev.py
+++ b/atr/routes/dev.py
@@ -16,12 +16,12 @@
 # under the License.
 
 
+import quart
+
 import asfquart as asfquart
 import asfquart.auth as auth
 import asfquart.base as base
 import asfquart.session as session
-import quart
-
 import atr.db as db
 import atr.db.models as models
 import atr.routes as routes
diff --git a/atr/routes/docs.py b/atr/routes/docs.py
index c4fb156..5d350a1 100644
--- a/atr/routes/docs.py
+++ b/atr/routes/docs.py
@@ -17,10 +17,10 @@
 
 """docs.py"""
 
-import asfquart as asfquart
-import asfquart.auth as auth
 import quart
 
+import asfquart as asfquart
+import asfquart.auth as auth
 import atr.routes as routes
 
 
diff --git a/atr/routes/download.py b/atr/routes/download.py
index 783bbfc..6ff0dbf 100644
--- a/atr/routes/download.py
+++ b/atr/routes/download.py
@@ -21,20 +21,20 @@ import pathlib
 
 import aiofiles
 import aiofiles.os
-import asfquart.auth as auth
-import asfquart.base as base
-import asfquart.session as session
 import quart
 import werkzeug.wrappers.response as response
 
+import asfquart.auth as auth
+import asfquart.base as base
+import asfquart.session as session
 import atr.db as db
 import atr.routes as routes
 import atr.util as util
 
 
-@routes.app_route("/download/<release_key>/<artifact_sha3>")
+@routes.app_route("/download/<release_name>/<artifact_sha3>")
 @auth.require(auth.Requirements.committer)
-async def root_download_artifact(release_key: str, artifact_sha3: str) -> response.Response | quart.Response:
+async def root_download_artifact(release_name: str, artifact_sha3: str) -> response.Response | quart.Response:
     """Download an artifact file."""
     # TODO: This function is very similar to the signature download function
     # We should probably extract the common code into a helper function
@@ -46,7 +46,7 @@ async def root_download_artifact(release_key: str, artifact_sha3: str) -> respon
         # Find the package
         package = await data.package(
             artifact_sha3=artifact_sha3,
-            release_key=release_key,
+            release_name=release_name,
             _release_committee=True,
         ).get()
 
@@ -76,9 +76,9 @@ async def root_download_artifact(release_key: str, artifact_sha3: str) -> respon
         )
 
 
-@routes.app_route("/download/signature/<release_key>/<signature_sha3>")
+@routes.app_route("/download/signature/<release_name>/<signature_sha3>")
 @auth.require(auth.Requirements.committer)
-async def root_download_signature(release_key: str, signature_sha3: str) -> quart.Response | response.Response:
+async def root_download_signature(release_name: str, signature_sha3: str) -> quart.Response | response.Response:
     """Download a signature file."""
     web_session = await session.read()
     if (web_session is None) or (web_session.uid is None):
@@ -87,7 +87,7 @@ async def root_download_signature(release_key: str, signature_sha3: str) -> quar
     async with db.session() as data:
         # Find the package that has this signature
         package = await data.package(
-            signature_sha3=signature_sha3, release_key=release_key, _release_committee=True
+            signature_sha3=signature_sha3, release_name=release_name, _release_committee=True
         ).get()
         if not package:
             await quart.flash("Signature not found", "error")
diff --git a/atr/routes/files.py b/atr/routes/files.py
new file mode 100644
index 0000000..afb97e8
--- /dev/null
+++ b/atr/routes/files.py
@@ -0,0 +1,159 @@
+# 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.
+
+"""package.py"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any, NoReturn, Protocol, TypeVar
+
+import asfquart.auth as auth
+import asfquart.base as base
+import asfquart.session as session
+import quart
+
+import atr.db as db
+import atr.db.models as models
+import atr.routes as routes
+
+if TYPE_CHECKING:
+    from collections.abc import Awaitable, Callable
+
+R = TypeVar("R", covariant=True)
+
+
+# This is the type of functions to which we apply @committer_get
+# In other words, functions which accept CommitterSession as their first arg
+class CommitterRouteHandler(Protocol[R]):
+    """Protocol for @committer_get decorated functions."""
+
+    __name__: str
+    __doc__: str | None
+
+    def __call__(self, session: CommitterSession, *args: Any, **kwargs: Any) -> Awaitable[R]: ...
+
+
+class CommitterSession:
+    """Session with extra information about committers."""
+
+    def __init__(self, web_session: session.ClientSession) -> None:
+        self._session = web_session
+
+    def __getattr__(self, name: str) -> Any:
+        # TODO: Not type safe, should subclass properly if possible
+        # For example, we can access session.no_such_attr and the type checkers won't notice
+        return getattr(self._session, name)
+
+    @property
+    def host(self) -> str:
+        request_host = quart.request.host
+        if ":" in request_host:
+            domain, port = request_host.split(":")
+            # Could be an IPv6 address, so need to check whether port is a valid integer
+            if port.isdigit():
+                return domain
+        return request_host
+
+    @property
+    async def user_projects(self) -> list[Any]:
+        user_projects: list[models.Project] = []
+        async with db.session() as data:
+            projects = await data.project(_committee=True, _releases=True).all()
+            for p in projects:
+                if p.committee is None:
+                    continue
+                if (self.uid in p.committee.committee_members) or (self.uid in p.committee.committers):
+                    user_projects.append(p)
+
+        return user_projects
+
+    @property
+    async def user_releases(self) -> list[Any]:
+        user_releases: list[models.Release] = []
+        async with db.session() as data:
+            # TODO: We're limiting this to candidates
+            # We should either call this user_candidate_releases, or change the query
+            releases = await data.release(stage=models.ReleaseStage.CANDIDATE, _project=True, _committee=True).all()
+            user_releases = []
+            for r in releases:
+                if r.committee is None:
+                    continue
+                if (self.uid in r.committee.committee_members) or (self.uid in r.committee.committers):
+                    user_releases.append(r)
+
+        return user_releases
+
+
+# This is the type of functions to which we apply @app_route
+# In other words, functions which accept no session
+class RouteHandler(Protocol[R]):
+    """Protocol for @app_route decorated functions."""
+
+    __name__: str
+    __doc__: str | None
+
+    def __call__(self, *args: Any, **kwargs: Any) -> Awaitable[R]: ...
+
+
+def _authentication_failed() -> NoReturn:
+    """Handle authentication failure with an exception."""
+    # NOTE: This is a separate function to fix a problem with analysis flow in mypy
+    raise base.ASFQuartException("Not authenticated", errorcode=401)
+
+
+# This decorator is an adaptor between @committer_get and @app_route functions
+def committer_get(path: str) -> Callable[[CommitterRouteHandler[R]], RouteHandler[R]]:
+    """Decorator for committer GET routes that provides an enhanced session object."""
+
+    def decorator(func: CommitterRouteHandler[R]) -> RouteHandler[R]:
+        async def wrapper(*args: Any, **kwargs: Any) -> R:
+            web_session = await session.read()
+            if web_session is None:
+                _authentication_failed()
+
+            enhanced_session = CommitterSession(web_session)
+            return await func(enhanced_session, *args, **kwargs)
+
+        # Set the name before applying decorators
+        wrapper.__name__ = func.__name__
+        wrapper.__doc__ = func.__doc__
+
+        # Apply decorators in reverse order
+        decorated = auth.require(auth.Requirements.committer)(wrapper)
+        decorated = routes.app_route(path, methods=["GET"])(decorated)
+
+        return decorated
+
+    return decorator
+
+
+def number_of_release_files(release: models.Release) -> int:
+    """Return the number of files in the release."""
+    # TODO: Return the number of files in the release
+    return 0
+
+
+@committer_get("/files/add")
+async def root_files_add(session: CommitterSession) -> str:
+    """Show a page to allow the user to rsync files to editable releases."""
+    return await quart.render_template(
+        "files-add.html",
+        asf_id=session.uid,
+        projects=await session.user_projects,
+        server_domain=session.host,
+        number_of_release_files=number_of_release_files,
+    )
diff --git a/atr/routes/keys.py b/atr/routes/keys.py
index 97b7024..04930f8 100644
--- a/atr/routes/keys.py
+++ b/atr/routes/keys.py
@@ -29,15 +29,16 @@ import shutil
 import tempfile
 from collections.abc import AsyncGenerator, Sequence
 
-import asfquart.auth as auth
-import asfquart.base as base
-import asfquart.session as session
 import cryptography.hazmat.primitives.serialization as serialization
 import gnupg
 import quart
 import werkzeug.wrappers.response as response
 import wtforms
 
+import asfquart as asfquart
+import asfquart.auth as auth
+import asfquart.base as base
+import asfquart.session as session
 import atr.db as db
 import atr.db.models as models
 import atr.routes as routes
diff --git a/atr/routes/package.py b/atr/routes/package.py
index c514b9f..38314f3 100644
--- a/atr/routes/package.py
+++ b/atr/routes/package.py
@@ -28,13 +28,13 @@ from collections.abc import Sequence
 
 import aiofiles
 import aiofiles.os
-import asfquart.auth as auth
-import asfquart.base as base
-import asfquart.session as session
 import quart
 import werkzeug.datastructures as datastructures
 import werkzeug.wrappers.response as response
 
+import asfquart.auth as auth
+import asfquart.base as base
+import asfquart.session as session
 import atr.db as db
 import atr.db.models as models
 import atr.routes as routes
@@ -112,7 +112,7 @@ async def package_add_artifact_info_get(
 
 async def package_add_session_process(
     data: db.Session,
-    release_key: str,
+    release_name: str,
     artifact_file: datastructures.FileStorage,
     checksum_file: datastructures.FileStorage | None,
     signature_file: datastructures.FileStorage | None,
@@ -120,7 +120,7 @@ async def package_add_session_process(
     """Helper function for package_add_post."""
 
     # First check for duplicates by filename
-    duplicate = await data.package(release_key=release_key, filename=util.unwrap(artifact_file.filename)).get()
+    duplicate = await data.package(release_name=release_name, filename=util.unwrap(artifact_file.filename)).get()
     if duplicate:
         raise routes.FlashError("This release artifact has already been uploaded")
 
@@ -168,8 +168,8 @@ async def package_add_validate(
 
     # TODO: Check that the submitter is a committer of the project
 
-    release_key = form.get("release_key")
-    if (not release_key) or (not isinstance(release_key, str)):
+    release_name = form.get("release_name")
+    if (not release_name) or (not isinstance(release_name, str)):
         raise routes.FlashError("Release key is required")
 
     # Get all uploaded files
@@ -191,17 +191,17 @@ async def package_add_validate(
     if artifact_type not in ["source", "binary", "reproducible"]:
         raise routes.FlashError("Invalid artifact type")
 
-    return release_key, artifact_file, checksum_file, signature_file, artifact_type
+    return release_name, artifact_file, checksum_file, signature_file, artifact_type
 
 
-async def package_data_get(data: db.Session, artifact_sha3: str, release_key: str, session_uid: str) -> models.Package:
+async def package_data_get(data: db.Session, artifact_sha3: str, release_name: str, session_uid: str) -> models.Package:
     """Validate package deletion request and return the package if valid."""
     # Get the package and its associated release
     package = await data.package(artifact_sha3=artifact_sha3, _release_committee=True).demand(
         routes.FlashError("Package not found")
     )
 
-    if package.release_key != release_key:
+    if package.release_name != release_name:
         raise routes.FlashError("Invalid release key")
 
     # Check permissions
@@ -221,8 +221,8 @@ async def package_add_bulk_validate(
     form: datastructures.MultiDict, request: quart.Request
 ) -> tuple[str, str, list[str], bool, int]:
     """Validate bulk package addition form data."""
-    release_key = form.get("release_key")
-    if (not release_key) or (not isinstance(release_key, str)):
+    release_name = form.get("release_name")
+    if (not release_name) or (not isinstance(release_name, str)):
         raise routes.FlashError("Release key is required")
 
     url = form.get("url")
@@ -254,13 +254,13 @@ async def package_add_bulk_validate(
     except (TypeError, ValueError):
         raise routes.FlashError("Maximum depth must be between 1 and 10 inclusive")
 
-    return release_key, url, file_types, require_signatures, max_depth
+    return release_name, url, file_types, require_signatures, max_depth
 
 
 async def package_add_single_post(form: datastructures.MultiDict, request: quart.Request) -> response.Response:
     """Process single package upload submission."""
     try:
-        release_key, artifact_file, checksum_file, signature_file, artifact_type = await package_add_validate(request)
+        release_name, artifact_file, checksum_file, signature_file, artifact_type = await package_add_validate(request)
     except routes.FlashError as e:
         logging.exception("FlashError:")
         await quart.flash(f"{e!s}", "error")
@@ -277,7 +277,7 @@ async def package_add_single_post(form: datastructures.MultiDict, request: quart
             try:
                 try:
                     artifact_sha3, artifact_size, artifact_sha512, signature_sha3 = await package_add_session_process(
-                        data, release_key, artifact_file, checksum_file, signature_file
+                        data, release_name, artifact_file, checksum_file, signature_file
                     )
                 except routes.FlashError as e:
                     logging.exception("FlashError:")
@@ -291,7 +291,7 @@ async def package_add_single_post(form: datastructures.MultiDict, request: quart
                     filename=artifact_file.filename,
                     signature_sha3=signature_sha3,
                     sha512=artifact_sha512,
-                    release_key=release_key,
+                    release_name=release_name,
                     uploaded=datetime.datetime.now(datetime.UTC),
                     bytes_size=artifact_size,
                 )
@@ -308,7 +308,7 @@ async def package_add_single_post(form: datastructures.MultiDict, request: quart
 async def package_add_bulk_post(form: datastructures.MultiDict, request: quart.Request) -> response.Response:
     """Process bulk package URL submission."""
     try:
-        release_key, url, file_types, require_signatures, max_depth = await package_add_bulk_validate(form, request)
+        release_name, url, file_types, require_signatures, max_depth = await package_add_bulk_validate(form, request)
     except routes.FlashError as e:
         logging.exception("FlashError:")
         await quart.flash(f"{e!s}", "error")
@@ -322,7 +322,7 @@ async def package_add_bulk_post(form: datastructures.MultiDict, request: quart.R
                 status=models.TaskStatus.QUEUED,
                 task_type="package_bulk_download",
                 task_args={
-                    "release_key": release_key,
+                    "release_name": release_name,
                     "base_url": url,
                     "file_types": file_types,
                     "require_sigs": require_signatures,
@@ -356,8 +356,8 @@ async def root_package_add() -> response.Response | str:
         else:
             return await package_add_single_post(form, quart.request)
 
-    # Get the storage_key from the query parameters (if redirected from create)
-    storage_key = quart.request.args.get("storage_key")
+    # Get the release name from the query parameters (if redirected from create)
+    release_name = quart.request.args.get("name")
 
     # Get all releases where the user is a PMC member or committer of the associated PMC
     async with db.session() as data:
@@ -379,7 +379,7 @@ async def root_package_add() -> response.Response | str:
         "package-add.html",
         asf_id=web_session.uid,
         releases=user_releases,
-        selected_release=storage_key,
+        selected_release=release_name,
     )
 
 
@@ -395,12 +395,12 @@ async def root_package_check() -> str | response.Response:
     if quart.request.method == "POST":
         form = await routes.get_form(quart.request)
         artifact_sha3 = form.get("artifact_sha3")
-        release_key = form.get("release_key")
+        release_name = form.get("release_name")
     else:
         artifact_sha3 = quart.request.args.get("artifact_sha3")
-        release_key = quart.request.args.get("release_key")
+        release_name = quart.request.args.get("release_name")
 
-    if not artifact_sha3 or not release_key:
+    if not artifact_sha3 or not release_name:
         await quart.flash("Missing required parameters", "error")
         return quart.redirect(quart.url_for("root_candidate_review"))
 
@@ -408,7 +408,7 @@ async def root_package_check() -> str | response.Response:
         async with data.begin():
             # Get the package and verify permissions
             try:
-                package = await package_data_get(data, artifact_sha3, release_key, web_session.uid)
+                package = await package_data_get(data, artifact_sha3, release_name, web_session.uid)
             except routes.FlashError as e:
                 logging.exception("FlashError:")
                 await quart.flash(str(e), "error")
@@ -430,7 +430,7 @@ async def root_package_check() -> str | response.Response:
 
                 await quart.flash(f"Added verification tasks for package {package.filename}", "success")
                 return quart.redirect(
-                    quart.url_for("root_package_check", artifact_sha3=artifact_sha3, release_key=release_key)
+                    quart.url_for("root_package_check", artifact_sha3=artifact_sha3, release_name=release_name)
                 )
             else:
                 # Get all tasks for this package for GET request
@@ -459,9 +459,9 @@ async def root_package_check_restart() -> response.Response:
 
     form = await routes.get_form(quart.request)
     artifact_sha3 = form.get("artifact_sha3")
-    release_key = form.get("release_key")
+    release_name = form.get("release_name")
 
-    if not artifact_sha3 or not release_key:
+    if not artifact_sha3 or not release_name:
         await quart.flash("Missing required parameters", "error")
         return quart.redirect(quart.url_for("root_candidate_review"))
 
@@ -469,7 +469,7 @@ async def root_package_check_restart() -> response.Response:
         async with data.begin():
             # Get the package and verify permissions
             try:
-                package = await package_data_get(data, artifact_sha3, release_key, web_session.uid)
+                package = await package_data_get(data, artifact_sha3, release_name, web_session.uid)
             except routes.FlashError as e:
                 logging.exception("FlashError:")
                 await quart.flash(str(e), "error")
@@ -480,7 +480,7 @@ async def root_package_check_restart() -> response.Response:
             if has_active_tasks:
                 await quart.flash("Cannot restart checks while tasks are still in progress", "error")
                 return quart.redirect(
-                    quart.url_for("root_package_check", artifact_sha3=artifact_sha3, release_key=release_key)
+                    quart.url_for("root_package_check", artifact_sha3=artifact_sha3, release_name=release_name)
                 )
 
             # Delete existing tasks
@@ -496,7 +496,7 @@ async def root_package_check_restart() -> response.Response:
 
             await quart.flash("Package checks restarted successfully", "success")
             return quart.redirect(
-                quart.url_for("root_package_check", artifact_sha3=artifact_sha3, release_key=release_key)
+                quart.url_for("root_package_check", artifact_sha3=artifact_sha3, release_name=release_name)
             )
 
 
@@ -510,16 +510,16 @@ async def root_package_delete() -> response.Response:
 
     form = await routes.get_form(quart.request)
     artifact_sha3 = form.get("artifact_sha3")
-    release_key = form.get("release_key")
+    release_name = form.get("release_name")
 
-    if not artifact_sha3 or not release_key:
+    if not artifact_sha3 or not release_name:
         await quart.flash("Missing required parameters", "error")
         return quart.redirect(quart.url_for("root_candidate_review"))
 
     async with db.session() as data:
         async with data.begin():
             try:
-                package = await package_data_get(data, artifact_sha3, release_key, web_session.uid)
+                package = await package_data_get(data, artifact_sha3, release_name, web_session.uid)
                 await routes.package_files_delete(package, pathlib.Path(util.get_release_storage_dir()))
                 await data.delete(package)
             except routes.FlashError as e:
diff --git a/atr/routes/projects.py b/atr/routes/projects.py
index c1d962b..72bf39d 100644
--- a/atr/routes/projects.py
+++ b/atr/routes/projects.py
@@ -19,12 +19,12 @@
 
 import http.client
 
-import asfquart.base as base
-import asfquart.session as session
 import quart
 import werkzeug.wrappers.response as response
 import wtforms
 
+import asfquart.base as base
+import asfquart.session as session
 import atr.db as db
 import atr.db.models as models
 import atr.routes as routes
diff --git a/atr/routes/release.py b/atr/routes/release.py
index d9e5565..fbc8557 100644
--- a/atr/routes/release.py
+++ b/atr/routes/release.py
@@ -21,13 +21,13 @@ import logging
 import logging.handlers
 import pathlib
 
+import quart
+import werkzeug.wrappers.response as response
+
 import asfquart
 import asfquart.auth as auth
 import asfquart.base as base
 import asfquart.session as session
-import quart
-import werkzeug.wrappers.response as response
-
 import atr.db as db
 import atr.db.models as models
 import atr.db.service as service
@@ -41,11 +41,9 @@ if asfquart.APP is ...:
 # Release functions
 
 
-async def release_delete_validate(data: db.Session, release_key: str, session_uid: str) -> models.Release:
+async def release_delete_validate(data: db.Session, release_name: str, session_uid: str) -> models.Release:
     """Validate release deletion request and return the release if valid."""
-    release = await data.release(storage_key=release_key, _committee=True).demand(
-        routes.FlashError("Release not found")
-    )
+    release = await data.release(name=release_name, _committee=True).demand(routes.FlashError("Release not found"))
 
     # Check permissions
     if release.committee:
@@ -75,16 +73,16 @@ async def root_release_delete() -> response.Response:
         raise base.ASFQuartException("Not authenticated", errorcode=401)
 
     form = await routes.get_form(quart.request)
-    release_key = form.get("release_key")
+    release_name = form.get("release_name")
 
-    if not release_key:
+    if not release_name:
         await quart.flash("Missing required parameters", "error")
         return quart.redirect(quart.url_for("root_candidate_review"))
 
     async with db.session() as data:
         async with data.begin():
             try:
-                release = await release_delete_validate(data, release_key, web_session.uid)
+                release = await release_delete_validate(data, release_name, web_session.uid)
                 await release_files_delete(release, pathlib.Path(util.get_release_storage_dir()))
                 await data.delete(release)
             except routes.FlashError as e:
@@ -127,8 +125,8 @@ async def release_bulk_status(task_id: int) -> str | response.Response:
         release = None
         # Debug print the task.task_args using the logger
         logging.debug(f"Task args: {task.task_args}")
-        if task.task_args and isinstance(task.task_args, dict) and ("release_key" in task.task_args):
-            release = await data.release(storage_key=task.task_args["release_key"], _committee=True).get()
+        if task.task_args and isinstance(task.task_args, dict) and ("release_name" in task.task_args):
+            release = await data.release(name=task.task_args["release_name"], _committee=True).get()
 
             # Check whether the user has permission to view this task
             # Either they're a PMC member or committer for the release's PMC
@@ -151,19 +149,19 @@ async def root_release_vote() -> response.Response | str:
     if web_session is None:
         raise base.ASFQuartException("Not authenticated", errorcode=401)
 
-    release_key = quart.request.args.get("release_key", "")
+    release_name = quart.request.args.get("release_name", "")
     form = None
     if quart.request.method == "POST":
         form = await routes.get_form(quart.request)
-        release_key = form.get("release_key", "")
+        release_name = form.get("release_name", "")
 
-    if not release_key:
+    if not release_name:
         await quart.flash("No release key provided", "error")
         return quart.redirect(quart.url_for("root_candidate_review"))
 
-    release = await service.get_release_by_key(release_key)
+    release = await service.get_release_by_name(release_name)
     if release is None:
-        await quart.flash(f"Release with key {release_key} not found", "error")
+        await quart.flash(f"Release with key {release_name} not found", "error")
         return quart.redirect(quart.url_for("root_candidate_review"))
 
     # If POST, process the form and create a vote_initiate task
@@ -185,7 +183,7 @@ async def root_release_vote() -> response.Response | str:
             status=models.TaskStatus.QUEUED,
             task_type="vote_initiate",
             task_args=[
-                release_key,
+                release_name,
                 email_to,
                 vote_duration,
                 gpg_key_id,
diff --git a/atr/routes/vote_policy.py b/atr/routes/vote_policy.py
index 8d30d5c..f447235 100644
--- a/atr/routes/vote_policy.py
+++ b/atr/routes/vote_policy.py
@@ -17,12 +17,12 @@
 
 """vote_policy.py"""
 
-import asfquart.base as base
-import asfquart.session as session
 import quart
 import werkzeug.wrappers.response as response
 import wtforms
 
+import asfquart.base as base
+import asfquart.session as session
 import atr.db as db
 import atr.routes as routes
 import atr.util as util
diff --git a/atr/server.py b/atr/server.py
index 0355bc1..7efc8a0 100644
--- a/atr/server.py
+++ b/atr/server.py
@@ -22,15 +22,15 @@ import os
 from collections.abc import Iterable
 from typing import Any
 
-import asfquart
-import asfquart.base as base
-import asfquart.generics
-import asfquart.session
 import blockbuster
 import quart
 import quart_schema
 import werkzeug.routing as routing
 
+import asfquart
+import asfquart.base as base
+import asfquart.generics
+import asfquart.session
 import atr.blueprints as blueprints
 import atr.config as config
 import atr.db as db
@@ -60,6 +60,7 @@ def register_routes(app: base.QuartApp) -> tuple[str, ...]:
     import atr.routes.dev as dev
     import atr.routes.docs as docs
     import atr.routes.download as download
+    import atr.routes.files as files
     import atr.routes.keys as keys
     import atr.routes.package as package
     import atr.routes.projects as projects
@@ -97,12 +98,13 @@ def register_routes(app: base.QuartApp) -> tuple[str, ...]:
         dev.__name__,
         docs.__name__,
         download.__name__,
+        files.__name__,
         keys.__name__,
         package.__name__,
         projects.__name__,
         release.__name__,
-        vote_policy.__name__,
         root.__name__,
+        vote_policy.__name__,
     )
 
 
diff --git a/atr/static/css/bootstrap.custom.css b/atr/static/css/bootstrap.custom.css
index e69cf84..24cb8bc 100644
--- a/atr/static/css/bootstrap.custom.css
+++ b/atr/static/css/bootstrap.custom.css
@@ -11449,6 +11449,10 @@ th {
   font-weight: 525;
 }
 
+.atr-cursor-pointer {
+  cursor: pointer;
+}
+
 small, .small, .text-muted {
   font-variation-settings: "opsz" 14;
 }
diff --git a/atr/tasks/bulk.py b/atr/tasks/bulk.py
index a223215..c49d0f7 100644
--- a/atr/tasks/bulk.py
+++ b/atr/tasks/bulk.py
@@ -59,7 +59,7 @@ global_task_id: int | None = None
 # TODO: Use a Pydantic model instead
 @dataclasses.dataclass
 class Args:
-    release_key: str
+    release_name: str
     base_url: str
     file_types: list[str]
     require_sigs: bool
@@ -75,7 +75,7 @@ class Args:
             _LOGGER.error(f"Invalid number of arguments: {len(args)}, expected 6")
             raise ValueError("Invalid number of arguments")
 
-        release_key = args["release_key"]
+        release_name = args["release_name"]
         base_url = args["base_url"]
         file_types = args["file_types"]
         require_sigs = args["require_sigs"]
@@ -83,13 +83,13 @@ class Args:
         max_concurrent = args["max_concurrent"]
 
         _LOGGER.debug(
-            f"Extracted values - release_key: {release_key}, base_url: {base_url}, "
+            f"Extracted values - release_name: {release_name}, base_url: {base_url}, "
             f"file_types: {file_types}, require_sigs: {require_sigs}, "
             f"max_depth: {max_depth}, max_concurrent: {max_concurrent}"
         )
 
-        if not isinstance(release_key, str):
-            _LOGGER.error(f"Release key must be a string, got {type(release_key)}")
+        if not isinstance(release_name, str):
+            _LOGGER.error(f"Release key must be a string, got {type(release_name)}")
             raise ValueError("Release key must be a string")
         if not isinstance(base_url, str):
             _LOGGER.error(f"Base URL must be a string, got {type(base_url)}")
@@ -114,7 +114,7 @@ class Args:
         _LOGGER.debug("All argument validations passed")
 
         args_obj = Args(
-            release_key=release_key,
+            release_name=release_name,
             base_url=base_url,
             file_types=file_types,
             require_sigs=require_sigs,
@@ -309,7 +309,7 @@ async def download_core(args_dict: dict[str, Any]) -> tuple[models.TaskStatus, s
     try:
         _LOGGER.debug(f"Parsing arguments: {args_dict}")
         args = Args.from_dict(args_dict)
-        _LOGGER.info(f"Args parsed successfully: release_key={args.release_key}, base_url={args.base_url}")
+        _LOGGER.info(f"Args parsed successfully: release_name={args.release_name}, base_url={args.base_url}")
 
         # Create async resources
         _LOGGER.debug("Creating async queue and semaphore")
diff --git a/atr/tasks/vote.py b/atr/tasks/vote.py
index 1fc73b3..992f7fb 100644
--- a/atr/tasks/vote.py
+++ b/atr/tasks/vote.py
@@ -50,7 +50,7 @@ _LOGGER.info("Vote module imported")
 class Args:
     """Arguments for the vote_initiate task."""
 
-    release_key: str
+    release_name: str
     email_to: str
     vote_duration: str
     gpg_key_id: str
@@ -66,7 +66,7 @@ class Args:
             _LOGGER.error(f"Invalid number of arguments: {len(args)}, expected 6")
             raise ValueError("Invalid number of arguments")
 
-        release_key = args[0]
+        release_name = args[0]
         email_to = args[1]
         vote_duration = args[2]
         gpg_key_id = args[3]
@@ -75,7 +75,7 @@ class Args:
 
         # Type checking
         for arg_name, arg_value in [
-            ("release_key", release_key),
+            ("release_name", release_name),
             ("email_to", email_to),
             ("vote_duration", vote_duration),
             ("gpg_key_id", gpg_key_id),
@@ -89,7 +89,7 @@ class Args:
         _LOGGER.debug("All argument validations passed")
 
         args_obj = Args(
-            release_key=release_key,
+            release_name=release_name,
             email_to=email_to,
             vote_duration=vote_duration,
             gpg_key_id=gpg_key_id,
@@ -116,8 +116,8 @@ def initiate(args: list[str]) -> tuple[models.TaskStatus, str | None, tuple[Any,
 
 def initiate_core(args_list: list[str]) -> tuple[models.TaskStatus, str | None, tuple[Any, ...]]:
     """Get arguments, create an email, and then send it to the recipient."""
+    import atr.db.service as service
     import atr.mail
-    from atr.db.service import get_release_by_key_sync
 
     test_recipients = ["sbp"]
     _LOGGER.info("Starting initiate_core")
@@ -140,9 +140,9 @@ def initiate_core(args_list: list[str]) -> tuple[models.TaskStatus, str | None,
         _LOGGER.info(f"Args parsed successfully: {args}")
 
         # Get the release information
-        release = get_release_by_key_sync(args.release_key)
+        release = service.get_release_by_name_sync(args.release_name)
         if not release:
-            error_msg = f"Release with key {args.release_key} not found"
+            error_msg = f"Release with key {args.release_name} not found"
             _LOGGER.error(error_msg)
             return task.FAILED, error_msg, tuple()
 
@@ -222,7 +222,7 @@ Thanks,
         # Create mail event with test recipient
         # Use test account instead of actual PMC list
         event = atr.mail.VoteEvent(
-            release_key=args.release_key,
+            release_name=args.release_name,
             email_recipient=test_recipient,
             subject=subject,
             body=body,
diff --git a/atr/templates/candidate-review.html b/atr/templates/candidate-review.html
index 9004b61..7cf1e02 100644
--- a/atr/templates/candidate-review.html
+++ b/atr/templates/candidate-review.html
@@ -42,11 +42,11 @@
           </div>
           <div class="d-flex gap-3 align-items-center pt-2">
             <a class="btn btn-primary"
-               href="{{ url_for('root_package_add', storage_key=release.storage_key) }}">Add package</a>
+               href="{{ url_for('root_package_add', release_name=release.name) }}">Add package</a>
             <form method="post"
                   action="{{ url_for('root_release_delete') }}"
                   class="d-inline-block m-0">
-              <input type="hidden" name="release_key" value="{{ release.storage_key }}" />
+              <input type="hidden" name="release_name" value="{{ release.name }}" />
               <button type="submit"
                       class="btn btn-danger"
                       onclick="return confirm('Are you sure you want to delete this entire release? This will delete all packages and cannot be undone.')">
@@ -54,7 +54,7 @@
               </button>
             </form>
             <a class="btn btn-primary"
-               href="{{ url_for('root_release_vote', release_key=release.storage_key) }}">Start vote</a>
+               href="{{ url_for('root_release_vote', release_name=release.name) }}">Start vote</a>
           </div>
         </div>
       </div>
@@ -95,7 +95,7 @@
                   <input type="hidden"
                          name="artifact_sha3"
                          value="{{ package.artifact_sha3 }}" />
-                  <input type="hidden" name="release_key" value="{{ release.storage_key }}" />
+                  <input type="hidden" name="release_name" value="{{ release.name }}" />
                   <button type="submit" class="btn btn-primary">Check package</button>
                 </form>
               {% else %}
@@ -115,7 +115,7 @@
                 {{ status_parts|join(", ") }} ({{ completed_tasks }}/{{ total_tasks }} {{ "Check" if total_tasks == 1 else "Checks" }} Complete)
                 {% if package.tasks %}
                   <div class="mt-3">
-                    <a href="{{ url_for('root_package_check', artifact_sha3=package.artifact_sha3, release_key=release.storage_key) }}"
+                    <a href="{{ url_for('root_package_check', artifact_sha3=package.artifact_sha3, release_name=release.name) }}"
                        class="btn btn-primary">Show check status</a>
                   </div>
                 {% endif %}
@@ -126,10 +126,10 @@
             <th>Downloads</th>
             <td>
               <div class="d-flex flex-wrap gap-2 align-items-center">
-                <a href="{{ url_for('root_download_artifact', release_key=release.storage_key, artifact_sha3=package.artifact_sha3) }}"
+                <a href="{{ url_for('root_download_artifact', release_name=release.name, artifact_sha3=package.artifact_sha3) }}"
                    class="btn btn-success download-button">Download Artifact</a>
                 {% if package.signature_sha3 %}
-                  <a href="{{ url_for('root_download_signature', release_key=release.storage_key, signature_sha3=package.signature_sha3) }}"
+                  <a href="{{ url_for('root_download_signature', release_name=release.name, signature_sha3=package.signature_sha3) }}"
                      class="btn btn-success download-button">Download Signature</a>
                 {% endif %}
                 <a href="{{ url_for('root_docs_verify', filename=package.filename, artifact_sha3=package.artifact_sha3, sha512=package.sha512, has_signature='true' if package.signature_sha3 else 'false') }}"
@@ -146,7 +146,7 @@
                 <input type="hidden"
                        name="artifact_sha3"
                        value="{{ package.artifact_sha3 }}" />
-                <input type="hidden" name="release_key" value="{{ release.storage_key }}" />
+                <input type="hidden" name="release_name" value="{{ release.name }}" />
                 <button type="submit"
                         class="btn btn-danger"
                         onclick="return confirm('Are you sure you want to delete this package?')">
diff --git a/atr/templates/files-add.html b/atr/templates/files-add.html
new file mode 100644
index 0000000..f2e77f3
--- /dev/null
+++ b/atr/templates/files-add.html
@@ -0,0 +1,129 @@
+{% extends "layouts/base.html" %}
+
+{% block title %}
+  Add files ~ ATR
+{% endblock title %}
+
+{% block description %}
+  Add files to editable ongoing releases using rsync.
+{% endblock description %}
+
+{% block content %}
+  <h1>Add files</h1>
+  <p class="intro">
+    Welcome, <strong>{{ asf_id }}</strong>! This page allows you to manage files for editable ongoing releases across your projects using rsync.
+    <ul>
+      <li>Projects can have multiple ongoing releases simultaneously</li>
+      <li>
+        An ongoing release is only editable during specific phases of its lifecycle, and frozen otherwise:
+        <ul class="mb-0">
+          <li>Candidate releases are editable until submitted for voting</li>
+          <li>Approved releases are editable until they've been officially announced</li>
+        </ul>
+      </li>
+      <li>You can only create a new release if you are a member of the project's committee</li>
+    </ul>
+  </p>
+
+  <h2>Projects</h2>
+  <div class="row row-cols-1 row-cols-md-2 g-4 mb-5">
+    {% for project in projects %}
+      {% set editable_releases = project.editable_releases %}
+
+      {# Show card for creating a new release if allowed #}
+      {% if asf_id in project.committee.committee_members %}
+        <div class="col">
+          <div class="card h-100">
+            <div class="card-body position-relative">
+              <div class="position-absolute top-0 end-0 m-2">
+                <span class="badge bg-primary">Potential</span>
+              </div>
+              <h5 class="card-title">{{ project.display_name }}</h5>
+              {% if project.committee %}
+                <h6 class="card-subtitle mb-2 text-muted">{{ project.committee.display_name }}</h6>
+              {% endif %}
+              <p class="card-text">
+                {% if editable_releases|length > 0 %}
+                  This project already has ongoing releases:
+                  {% for release in editable_releases %}<a href="#{{ release.name }}">{{ release.version }}</a>;{% endfor %}
+                  to create another one, use the command below.
+                {% else %}
+                  This project does not have an editable ongoing release.
+                  To create one and add files, use the command below.
+                {% endif %}
+              </p>
+            </div>
+            <pre class="card-footer bg-transparent border-top-0 small">
+rsync -av -e 'ssh -p 2222' your/files/ \
+    {{ asf_id }}@{{ server_domain }}:/{{ project.name }}/VERSION/
+            </pre>
+          </div>
+        </div>
+      {% else %}
+        <div class="col">
+          <div class="card h-100">
+            <div class="card-body position-relative">
+              <div class="position-absolute top-0 end-0 m-2">
+                <span class="badge bg-secondary">Ask</span>
+              </div>
+              <h5 class="card-title">{{ project.display_name }}</h5>
+              {% if project.committee %}
+                <h6 class="card-subtitle mb-2 text-muted">{{ project.committee.display_name }}</h6>
+              {% endif %}
+              <p class="card-text">
+                {% if editable_releases|length > 0 %}
+                  This project already has ongoing releases:
+                  {% for release in editable_releases %}<a href="#{{ release.name }}">{{ release.version }}</a>;{% endfor %}
+                  to create another one, you must be a member of the project's committee.
+                {% else %}
+                  This project does not have an editable ongoing release.
+                  To create one, you must be a member of the project's committee.
+                {% endif %}
+              </p>
+            </div>
+          </div>
+        </div>
+      {% endif %}
+    {% endfor %}
+  </div>
+
+  <h2>Editable ongoing releases</h2>
+  <div class="row row-cols-1 row-cols-md-2 g-4 mb-5">
+    {% set ns = namespace(has_releases=false) %}
+    {% for project in projects %}
+      {% for release in project.editable_releases %}
+        {% set ns.has_releases = true %}
+        <div class="col" id="{{ release.name }}">
+          <div class="card h-100">
+            <div class="card-body position-relative">
+              <div class="position-absolute top-0 end-0 m-2">
+                <span class="badge bg-success">Ongoing</span>
+              </div>
+              <h5 class="card-title">{{ project.display_name }} {{ release.version or "?" }}</h5>
+              {% if project.committee %}
+                <h6 class="card-subtitle mb-2 text-muted">{{ project.committee.display_name }}</h6>
+              {% endif %}
+              <p class="card-text">
+                {% if number_of_release_files(release) > 0 %}
+                  This editable ongoing release has {{ number_of_release_files(release) }} file(s).
+                {% else %}
+                  This editable ongoing release doesn't have any files yet.
+                {% endif %}
+                Use the command below to add or modify files in this release:
+              </p>
+            </div>
+            <pre class="card-footer bg-transparent border-top-0 small">
+rsync -av -e 'ssh -p 2222' your/files/ \
+    {{ asf_id }}@{{ server_domain }}:/{{ project.name }}/{{ release.version }}/
+            </pre>
+          </div>
+        </div>
+      {% endfor %}
+    {% endfor %}
+    {% if not ns.has_releases %}
+      <div class="col-12">
+        <div class="alert alert-info">There are currently no editable ongoing releases.</div>
+      </div>
+    {% endif %}
+  </div>
+{% endblock content %}
diff --git a/atr/templates/includes/sidebar.html b/atr/templates/includes/sidebar.html
index ead4f99..ce5f960 100644
--- a/atr/templates/includes/sidebar.html
+++ b/atr/templates/includes/sidebar.html
@@ -53,6 +53,10 @@
           <a href="{{ url_for('root_package_add') }}"
              {% if request.endpoint == 'root_package_add' %}class="active"{% endif %}>Add package</a>
         </li>
+        <li>
+          <a href="{{ url_for('root_files_add') }}"
+             {% if request.endpoint == 'root_files_add' %}class="active"{% endif %}>Add files</a>
+        </li>
         <li>
           <a href="{{ url_for('root_candidate_review') }}"
              {% if request.endpoint == 'root_candidate_review' %}class="active"{% endif %}>Review candidates</a>
diff --git a/atr/templates/package-add.html b/atr/templates/package-add.html
index 8745dec..e164465 100644
--- a/atr/templates/package-add.html
+++ b/atr/templates/package-add.html
@@ -21,14 +21,17 @@
       <tbody>
         <tr>
           <th class="text-end pe-3 align-top">
-            <label for="release_key">Release:</label>
+            <label for="release_name">Release:</label>
           </th>
           <td class="align-top">
-            <select id="release_key" name="release_key" class="form-select mb-2" required>
+            <select id="release_name"
+                    name="release_name"
+                    class="form-select mb-2"
+                    required>
               <option value="">Select a release...</option>
               {% for release in releases %}
-                <option value="{{ release.storage_key }}"
-                        {% if release.storage_key == selected_release %}selected{% endif %}>
+                <option value="{{ release.name }}"
+                        {% if release.name == selected_release %}selected{% endif %}>
                   {{ release.committee.display_name }} - {{ release.project.name if release.project else "unknown" }} - {{ release.version }}
                 </option>
               {% endfor %}
@@ -144,17 +147,17 @@
       <tbody>
         <tr>
           <th class="text-end pe-3 align-top">
-            <label for="bulk_release_key">Release:</label>
+            <label for="bulk_release_name">Release:</label>
           </th>
           <td class="align-top">
-            <select id="bulk_release_key"
-                    name="release_key"
+            <select id="bulk_release_name"
+                    name="release_name"
                     class="form-select mb-2"
                     required>
               <option value="">Select a release...</option>
               {% for release in releases %}
-                <option value="{{ release.storage_key }}"
-                        {% if release.storage_key == selected_release %}selected{% endif %}>
+                <option value="{{ release.name }}"
+                        {% if release.name == selected_release %}selected{% endif %}>
                   {{ release.committee.display_name }} - {{ release.project.name if release.project else "unknown" }} - {{ release.version }}
                 </option>
               {% endfor %}
diff --git a/atr/templates/package-check.html b/atr/templates/package-check.html
index 5cf3e53..13f6a77 100644
--- a/atr/templates/package-check.html
+++ b/atr/templates/package-check.html
@@ -79,7 +79,7 @@
         <input type="hidden"
                name="artifact_sha3"
                value="{{ package.artifact_sha3 }}" />
-        <input type="hidden" name="release_key" value="{{ release.storage_key }}" />
+        <input type="hidden" name="release_name" value="{{ release.name }}" />
         <button type="submit" class="btn btn-primary">Restart all checks</button>
       </form>
     {% endif %}
@@ -111,9 +111,9 @@
             {% if task.result and task.result[0] is mapping and task.result|length == 1 %}
               <details class="mt-2 p-2 bg-light rounded">
                 {% if task.error %}
-                  <summary class="cursor-pointer user-select-none p-2">Issue: {{ task.error }}</summary>
+                  <summary class="atr-cursor-pointer user-select-none p-2">Issue: {{ task.error }}</summary>
                 {% else %}
-                  <summary class="cursor-pointer user-select-none p-2">View detailed results</summary>
+                  <summary class="atr-cursor-pointer user-select-none p-2">View detailed results</summary>
                 {% endif %}
 
                 {% if task.task_type == 'verify_rat_license' and task.result[0] is mapping %}
diff --git a/atr/templates/project-directory.html b/atr/templates/project-directory.html
index 27deff8..2665ddc 100644
--- a/atr/templates/project-directory.html
+++ b/atr/templates/project-directory.html
@@ -22,7 +22,7 @@
   <div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-4">
     {% for project in projects %}
       <div class="col">
-        <div class="card h-100 shadow-sm cursor-pointer hover-lift project-card"
+        <div class="card h-100 shadow-sm atr-cursor-pointer hover-lift project-card"
              data-project-url="{{ url_for('root_project_view', name=project.name) }}">
           <div class="card-body">
             <h2 class="card-title fs-4 mb-3">{{ project.display_name }}</h2>
diff --git a/atr/templates/release-vote.html b/atr/templates/release-vote.html
index 1acb71c..d8f60c6 100644
--- a/atr/templates/release-vote.html
+++ b/atr/templates/release-vote.html
@@ -28,7 +28,7 @@
     <form method="post"
           class="striking py-4 px-5"
           action="{{ url_for('root_release_vote') }}">
-      <input type="hidden" name="release_key" value="{{ release.storage_key }}" />
+      <input type="hidden" name="release_name" value="{{ release.name }}" />
 
       <div class="mb-4">
         <div class="row mb-3 pb-3 border-bottom">
diff --git a/bootstrap/custom.scss b/bootstrap/custom.scss
index b122c1b..510d2d4 100644
--- a/bootstrap/custom.scss
+++ b/bootstrap/custom.scss
@@ -70,6 +70,11 @@ th {
   @extend .w-25;
 }
 
+// Not in Bootstrap
+.atr-cursor-pointer {
+  cursor: pointer;
+}
+
 small, .text-muted {
   font-variation-settings: "opsz" 14;
 }


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