asf-tooling commented on issue #473:
URL: 
https://github.com/apache/tooling-trusted-releases/issues/473#issuecomment-4410232527

   <!-- gofannon-issue-triage-bot v2 -->
   
   **Automated triage** — analyzed at `main@2da7807a`
   
   **Type:** `new_feature`  •  **Classification:** `actionable`  •  
**Confidence:** `medium`
   **Application domain(s):** `release_lifecycle`, `distribution_tracking`
   
   ### Summary
   The issue requests a new finish policy that allows users to automatically 
archive a prior release when finishing a new one. At start time, users select a 
prior version to archive; at finish/publish time, the prior release is 
archived. @sbp confirmed (2026-04-22) that archiving will only be implemented 
for prior releases made on ATR, not legacy releases. @dave2wave noted that a 
manual archive operation should also be available for all releases. The feature 
requires changes to the start form, release model storage, finish page UI, and 
the actual archive logic.
   
   ### Where this lives in the code today
   
   #### `atr/shared/start.py` — `StartReleaseForm` (lines 24-38)
   _needs modification_
   The start release form needs new fields: a checkbox to enable archiving and 
a select for the prior version to archive.
   
   ```python
   class StartReleaseForm(form.Form):
       version_key: str = form.label(
           "Version",
           "Enter the version string for this new release."
           " This cannot be changed later, and must be the version of the 
finished release."
           " ATR generates a unique revision serial number for each voting 
round,"
           " and you can also set your own tag before a vote starts.",
       )
   
       @pydantic.field_validator("version_key", mode="after")
       @classmethod
       def validate_version_key(cls, value: str) -> str:
           if error := util.version_key_error(value):
               raise ValueError(error)
           return value
   ```
   
   #### `atr/get/start.py` — `selected` (lines 37-53)
   _needs modification_
   The start page GET handler already fetches existing releases; it needs to 
pass published ATR releases as choices for the archive-prior-version select 
field.
   
   ```python
   @get.typed
   async def selected(session: web.Committer, _start: Literal["start"], 
project_key: safe.ProjectKey) -> str:
       """
       URL: /start/<project_key>
       """
       await session.prevent_confusing_ui_display(project_key)
       async with db.session() as data:
           project = await data.project(key=str(project_key), 
status=sql.ProjectStatus.ACTIVE).demand(
               base.ASFQuartException(f"Project {project_key} not found", 
errorcode=404)
           )
   
       releases = await interaction.all_releases(project)
       content = await _render_page(project, releases)
       return await template.blank(
           title=f"Start a new release for {project.display_name}",
           content=content,
       )
   ```
   
   #### `atr/post/start.py` — `selected` (lines 30-47)
   _needs modification_
   The start POST handler needs to pass the archive_prior_version value to the 
release.start() method or store it on the release.
   
   ```python
   @post.typed
   async def selected(
       session: web.Committer,
       _start: Literal["start"],
       project_key: safe.ProjectKey,
       start_release_form: shared.start.StartReleaseForm,
   ) -> web.WerkzeugResponse:
       """
       URL: /start/<project_key>
       """
   
       try:
           async with storage.write(session) as write:
               wacp = await write.as_project_committee_participant(project_key)
               new_release, _project = await wacp.release.start(
                   project_key,
                   safe.VersionKey(start_release_form.version_key),
               )
   ```
   
   #### `atr/shared/finish.py` — `FinishForm` (lines 31-47)
   _needs modification_
   The FinishForm discriminated union needs a new ARCHIVE_PRIOR variant for 
manually triggering prior release archival from the finish page.
   
   ```python
   type DELETE_DIR = Literal["DELETE_DIR"]
   type REMOVE_RC_TAGS = Literal["REMOVE_RC_TAGS"]
   
   
   class DeleteEmptyDirectoryForm(form.Form):
       variant: DELETE_DIR = form.value(DELETE_DIR)
       directory_to_delete: safe.RelPath = form.label("Directory to delete", 
widget=form.Widget.SELECT)
   
   
   class RemoveRCTagsForm(form.Empty):
       variant: REMOVE_RC_TAGS = form.value(REMOVE_RC_TAGS)
   
   
   type FinishForm = Annotated[
       DeleteEmptyDirectoryForm | RemoveRCTagsForm,
       form.DISCRIMINATOR,
   ]
   ```
   
   #### `atr/post/finish.py` — `selected` (lines 32-49)
   _needs modification_
   The finish POST handler needs a new match case for ArchivePriorReleaseForm 
to handle the archive action.
   
   ```python
   @post.typed
   async def selected(
       session: web.Committer,
       _finish: Literal["finish"],
       project_key: safe.ProjectKey,
       version_key: safe.VersionKey,
       finish_form: shared.finish.FinishForm,
   ) -> tuple[web.QuartResponse, int] | web.WerkzeugResponse:
       """
       URL: /finish/<project_key>/<version_key>
       """
       respond = _respond_helper(session, project_key, version_key)
   
       match finish_form:
           case shared.finish.DeleteEmptyDirectoryForm() as delete_form:
               return await _delete_empty_directory(delete_form, session, 
project_key, version_key, respond)
           case shared.finish.RemoveRCTagsForm():
               return await _remove_rc_tags(session, project_key, version_key, 
respond)
   ```
   
   #### `atr/get/finish.py` — `_render_page` (lines 288-311)
   _needs modification_
   The finish page renderer needs a new section for the 'Archive prior release' 
action with a form showing archivable prior releases.
   
   ```python
   async def _render_page(
       release: sql.Release,
       deletable_dirs: list[tuple[str, str]],
       rc_analysis: RCTagAnalysisResult,
       distribution_tasks: Sequence[sql.Task],
       announce_disable_message: str,
   ) -> str:
       """Render the finish page using htm.py."""
       page = htm.Block()
   
       render.html_nav(
           page,
           back_url=util.as_url(root.index),
           back_anchor="Select a release",
           phase="FINISH",
       )
   
       # Page heading
       page.h1[
           "Finish ",
           htm.strong[release.project.short_display_name],
           " ",
           htm.em[release.version],
       ]
   ```
   
   ### Where new code would go
   - `atr/models/sql.py` — Release model class
     A new nullable column (e.g., archive_prior_version) on the Release model 
to store which prior version should be archived when this release is published.
   - `atr/storage/writers/release.py` — within CommitteeMember or 
CommitteeParticipant class
     A new method like archive_prior_release() to handle the actual archiving 
logic (moving prior release artifacts to archive state).
   
   ### Proposed approach
   The implementation has two main parts, based on @sbp's decision to only 
support archiving prior ATR releases:
   
   1. **Start-time selection**: Extend `StartReleaseForm` with an optional 
`archive_prior_version` field (a select populated with prior published ATR 
releases for the same project). Store this on the Release model as a nullable 
field. The `atr/get/start.py` page will query for published releases of the 
project and pass them as choices.
   
   2. **Finish-time action**: Add a new `ArchivePriorReleaseForm` variant to 
`FinishForm` in `atr/shared/finish.py`. On the finish page, render a section 
showing which prior release is queued for archival (from the start-time 
selection) with a button to execute it, plus a manual selector for cases where 
it wasn't set at start time (per @dave2wave's requirement). The archive 
operation itself marks the prior release as archived in the database and 
moves/removes its artifacts from the distribution directory. The actual 
archiving logic belongs in `atr/storage/writers/release.py` since it modifies 
release state.
   
   ### Suggested patches
   
   #### `atr/shared/start.py`
   Add optional archive_prior_version field to StartReleaseForm
   
   ````diff
   --- a/atr/shared/start.py
   +++ b/atr/shared/start.py
   @@ -18,6 +18,7 @@
    import pydantic
    
    import atr.form as form
   +import atr.models.safe as safe
    import atr.util as util
    
    
   @@ -29,6 +30,13 @@ class StartReleaseForm(form.Form):
            " and you can also set your own tag before a vote starts.",
        )
    
   +    archive_prior_version: str = form.label(
   +        "Archive prior release",
   +        "Optionally select a prior ATR release to archive when this release 
is published.",
   +        widget=form.Widget.SELECT,
   +        required=False,
   +    )
   +
        @pydantic.field_validator("version_key", mode="after")
        @classmethod
        def validate_version_key(cls, value: str) -> str:
   ````
   
   #### `atr/shared/finish.py`
   Add ArchivePriorReleaseForm variant to the FinishForm discriminated union
   
   ````diff
   --- a/atr/shared/finish.py
   +++ b/atr/shared/finish.py
   @@ -27,6 +27,7 @@ type Respond = Callable[[int, str], 
Awaitable[tuple[web.QuartResponse, int] | we
    
    type DELETE_DIR = Literal["DELETE_DIR"]
    type REMOVE_RC_TAGS = Literal["REMOVE_RC_TAGS"]
   +type ARCHIVE_PRIOR = Literal["ARCHIVE_PRIOR"]
    
    
    class DeleteEmptyDirectoryForm(form.Form):
   @@ -38,7 +39,12 @@ class RemoveRCTagsForm(form.Empty):
        variant: REMOVE_RC_TAGS = form.value(REMOVE_RC_TAGS)
    
    
   +class ArchivePriorReleaseForm(form.Form):
   +    variant: ARCHIVE_PRIOR = form.value(ARCHIVE_PRIOR)
   +    prior_version: str = form.label("Prior release to archive", 
widget=form.Widget.SELECT)
   +
   +
    type FinishForm = Annotated[
   -    DeleteEmptyDirectoryForm | RemoveRCTagsForm,
   +    DeleteEmptyDirectoryForm | RemoveRCTagsForm | ArchivePriorReleaseForm,
        form.DISCRIMINATOR,
    ]
   ````
   
   #### `atr/post/finish.py`
   Add match case for ArchivePriorReleaseForm to handle archive requests
   
   ````diff
   --- a/atr/post/finish.py
   +++ b/atr/post/finish.py
   @@ -37,6 +37,8 @@ async def selected(
            case shared.finish.DeleteEmptyDirectoryForm() as delete_form:
                return await _delete_empty_directory(delete_form, session, 
project_key, version_key, respond)
            case shared.finish.RemoveRCTagsForm():
                return await _remove_rc_tags(session, project_key, version_key, 
respond)
   +        case shared.finish.ArchivePriorReleaseForm() as archive_form:
   +            return await _archive_prior_release(archive_form, session, 
project_key, version_key, respond)
    
    
    async def _delete_empty_directory(
   @@ -92,6 +94,33 @@ async def _remove_rc_tags(
            return await _server_error(respond, e, f"Unexpected error: {e!s}")
    
    
   +async def _archive_prior_release(
   +    archive_form: shared.finish.ArchivePriorReleaseForm,
   +    session: web.Committer,
   +    project_key: safe.ProjectKey,
   +    version_key: safe.VersionKey,
   +    respond: shared.finish.Respond,
   +) -> tuple[web.QuartResponse, int] | web.WerkzeugResponse:
   +    prior_version = archive_form.prior_version
   +    try:
   +        async with storage.write(session) as write:
   +            wacp = await write.as_project_committee_member(project_key)
   +            # TODO: Implement wacp.release.archive_prior_release() in 
storage/writers/release.py
   +            creation_error = await wacp.release.archive_prior_release(
   +                project_key,
   +                version_key,
   +                safe.VersionKey(prior_version),
   +            )
   +    except Exception as e:
   +        log.exception(
   +            f"Unexpected error archiving prior release {prior_version} for 
{project_key}/{version_key}"
   +        )
   +        return await _server_error(respond, e, "An unexpected error 
occurred while archiving.")
   +
   +    if creation_error is not None:
   +        return await respond(400, creation_error)
   +    return await respond(200, f"Archived prior release '{prior_version}'.")
   +
   +
    def _respond_helper(
        session: web.Committer, project_key: safe.ProjectKey, version_key: 
safe.VersionKey
    ) -> shared.finish.Respond:
   ````
   
   ### Open questions
   - What does 'archive' mean concretely in this context? Is it setting a 
release phase/status (e.g., ARCHIVED) in the DB, moving files from dist to 
archive.apache.org, or both?
   - Where in atr/models/sql.py should the archive_prior_version field be 
stored? On the Release model directly, or in a separate table/policy?
   - The archive_prior_release() method needs to be implemented in 
atr/storage/writers/release.py — what exact operations does it perform (DB 
status change, filesystem move, both)?
   - Should archiving automatically trigger when 'Announce and publish' is 
clicked (if the flag was set at start time), or should it always be a separate 
manual action on the finish page?
   - The form.label() and form.Widget.SELECT usage in StartReleaseForm for 
archive_prior_version needs validation — is 'required=False' the correct way to 
make a form field optional in this framework?
   - How should the list of archivable prior versions be populated? Only 
RELEASE phase releases for the same project that were created on ATR (per 
@sbp's decision)?
   
   ### Files examined
   - `atr/get/finish.py`
   - `atr/post/finish.py`
   - `atr/shared/finish.py`
   - `atr/get/start.py`
   - `atr/post/start.py`
   - `atr/shared/start.py`
   - `atr/storage/writers/distributions.py`
   - `atr/post/distribution.py`
   
   ---
   *Draft from a triage agent. A human reviewer should validate before merging 
any change. The agent did not run tests or verify diffs apply.*


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


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

Reply via email to