This is an automated email from the ASF dual-hosted git repository. arm pushed a commit to branch gha-distributions in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git
commit cabce79158ee5a551c5a7a032239036b0da9534e Author: Alastair McFarlane <[email protected]> AuthorDate: Mon Jan 12 14:24:37 2026 +0000 Rename GHA task to be more specific, and work in extra required parameters. UI work to trigger distributions and endpoint to register an SSH key --- atr/api/__init__.py | 30 ++++++++- atr/db/interaction.py | 18 +++++ atr/form.py | 4 ++ atr/get/distribution.py | 85 ++++++++++++++++++----- atr/get/finish.py | 126 ++++++++++++++++++++++++++++------- atr/models/api.py | 15 +++++ atr/models/results.py | 6 +- atr/models/sql.py | 10 ++- atr/post/distribution.py | 107 ++++++++++++++++++++++++++--- atr/server.py | 3 +- atr/shared/distribution.py | 13 ++-- atr/storage/writers/distributions.py | 50 +++++++++++++- atr/tasks/__init__.py | 2 +- atr/tasks/gha.py | 96 +++++++++++++++++++++----- atr/templates/check-selected.html | 12 +++- 15 files changed, 492 insertions(+), 85 deletions(-) diff --git a/atr/api/__init__.py b/atr/api/__init__.py index 06c932e..e543b43 100644 --- a/atr/api/__init__.py +++ b/atr/api/__init__.py @@ -251,6 +251,32 @@ async def committees_list() -> DictResponse: ).model_dump(), 200 [email protected]("/distribute/ssh/register", methods=["POST"]) +@quart_schema.validate_request(models.api.DistributeSshRegisterArgs) +async def distribute_ssh_register(data: models.api.DistributeSshRegisterArgs) -> DictResponse: + """ + Register an SSH key sent with a corroborating Trusted Publisher JWT, + validating the requested version is in the correct phase. + """ + payload, asf_uid, project = await interaction.trusted_jwt_for_version( + data.publisher, data.jwt, interaction.TrustedProjectPhase(data.phase), data.version + ) + async with storage.write_as_committee_member(util.unwrap(project.committee).name, asf_uid) as wacm: + fingerprint, expires = await wacm.ssh.add_workflow_key( + payload["actor"], + payload["actor_id"], + project.name, + data.ssh_key, + ) + + return models.api.DistributeSshRegisterResults( + endpoint="/distribute/ssh/register", + fingerprint=fingerprint, + project=project.name, + expires=expires, + ).model_dump(), 200 + + @api.route("/distribution/record", methods=["POST"]) @jwtoken.require @quart_schema.security_scheme([{"BearerAuth": []}]) @@ -279,7 +305,7 @@ async def distribution_record(data: models.api.DistributionRecordArgs) -> DictRe async with storage.write(asf_uid) as write: wacm = write.as_committee_member(release.committee.name) await wacm.distributions.record_from_data( - release, + release.name, data.staging, dd, ) @@ -656,7 +682,7 @@ async def publisher_distribution_record(data: models.api.PublisherDistributionRe async with storage.write(asf_uid) as write: wacm = write.as_committee_member(release.committee.name) await wacm.distributions.record_from_data( - release, + release.name, data.staging, dd, ) diff --git a/atr/db/interaction.py b/atr/db/interaction.py index f8026f8..1bd097c 100644 --- a/atr/db/interaction.py +++ b/atr/db/interaction.py @@ -179,6 +179,24 @@ async def trusted_jwt(publisher: str, jwt: str, phase: TrustedProjectPhase) -> t return payload, asf_uid, project +async def trusted_jwt_for_version( + publisher: str, jwt: str, phase: TrustedProjectPhase, version_name: str +) -> tuple[dict[str, Any], str, sql.Project]: + payload, asf_uid, project = await trusted_jwt(publisher, jwt, phase) + async with db.session() as db_data: + release = await db_data.release(project_name=project.name, version=version_name).get() + if not release: + raise InteractionError(f"Release {version} does not exist in project {project.name}") + if phase == TrustedProjectPhase.COMPOSE and release.phase != sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT: + raise InteractionError(f"Release {version} is not in compose phase") + if phase == TrustedProjectPhase.VOTE and release.phase != sql.ReleasePhase.RELEASE_CANDIDATE: + raise InteractionError(f"Release {version} is not in vote phase") + if phase == TrustedProjectPhase.FINISH and release.phase != sql.ReleasePhase.RELEASE_PREVIEW: + raise InteractionError(f"Release {version} is not in finish phase") + + return payload, asf_uid, project + + async def has_failing_checks(release: sql.Release, revision_number: str, caller_data: db.Session | None = None) -> bool: async with db.ensure_session(caller_data) as data: query = ( diff --git a/atr/form.py b/atr/form.py index 6f40775..909b0e6 100644 --- a/atr/form.py +++ b/atr/form.py @@ -517,6 +517,10 @@ Email = pydantic.EmailStr class Enum[EnumType: enum.Enum]: + # These exist for type checkers - at runtime, the actual type is the enum + name: str + value: str | int + @staticmethod def __class_getitem__(enum_class: type[EnumType]): def validator(v: Any) -> EnumType: diff --git a/atr/get/distribution.py b/atr/get/distribution.py index 4f166a9..9a284d7 100644 --- a/atr/get/distribution.py +++ b/atr/get/distribution.py @@ -29,30 +29,30 @@ import atr.util as util import atr.web as web [email protected]("/distributions/list/<project>/<version>") -async def list_get(session: web.Committer, project: str, version: str) -> str: [email protected]("/distributions/list/<project_name>/<version_name>") +async def list_get(session: web.Committer, project_name: str, version_name: str) -> str: async with db.session() as data: distributions = await data.distribution( - release_name=sql.release_name(project, version), + release_name=sql.release_name(project_name, version_name), ).all() block = htm.Block() - release = await shared.distribution.release_validated(project, version, staging=None) + release = await shared.distribution.release_validated(project_name, version_name, staging=None) staging = release.phase == sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT - render.html_nav_phase(block, project, version, staging) + render.html_nav_phase(block, project_name, version_name, staging) record_a_distribution = htm.a( ".btn.btn-primary", href=util.as_url( - stage if staging else record, - project=project, - version=version, + stage_record if staging else record, + project=project_name, + version=version_name, ), )["Record a distribution"] # Distribution list for project-version - block.h1["Distribution list for ", htm.em[f"{project}-{version}"]] + block.h1["Distribution list for ", htm.em[f"{project_name}-{version_name}"]] if not distributions: block.p["No distributions found."] block.p[record_a_distribution] @@ -89,7 +89,7 @@ async def list_get(session: web.Committer, project: str, version: str) -> str: delete_form = form.render( model_cls=shared.distribution.DeleteForm, - action=util.as_url(post.distribution.delete, project=project, version=version), + action=util.as_url(post.distribution.delete, project=project_name, version=version_name), form_classes=".d-inline-block.m-0", submit_classes="btn-danger btn-sm", submit_label="Delete", @@ -105,20 +105,71 @@ async def list_get(session: web.Committer, project: str, version: str) -> str: ) block.append(htm.div(".mb-3")[delete_form]) - title = f"Distribution list for {project} {version}" + title = f"Distribution list for {project_name} {version_name}" return await template.blank(title, content=block.collect()) [email protected]("/distribution/automate/<project>/<version>") +async def automate(session: web.Committer, project: str, version: str) -> str: + return await _automate_form_page(project, version, staging=False) + + [email protected]("/distribution/stage/automate/<project>/<version>") +async def stage_automate(session: web.Committer, project: str, version: str) -> str: + return await _automate_form_page(project, version, staging=True) + + @get.committer("/distribution/record/<project>/<version>") async def record(session: web.Committer, project: str, version: str) -> str: return await _record_form_page(project, version, staging=False) [email protected]("/distribution/stage/<project>/<version>") -async def stage(session: web.Committer, project: str, version: str) -> str: [email protected]("/distribution/stage/record/<project>/<version>") +async def stage_record(session: web.Committer, project: str, version: str) -> str: return await _record_form_page(project, version, staging=True) +async def _automate_form_page(project: str, version: str, staging: bool) -> str: + """Helper to render the distribution automation form page.""" + await shared.distribution.release_validated(project, version, staging=staging) + + block = htm.Block() + render.html_nav_phase(block, project, version, staging=staging) + + title = "Create a staging distribution" if staging else "Create a distribution" + block.h1[title] + + block.p[ + "Create a distribution of ", + htm.strong[f"{project}-{version}"], + " using the form below.", + ] + block.p[ + "You can also ", + htm.a(href=util.as_url(list_get, project_name=project, version_name=version))["view the distribution list"], + ".", + ] + + # Determine the action based on staging + action = ( + util.as_url(post.distribution.stage_automate_selected, project=project, version=version) + if staging + else util.as_url(post.distribution.automate_selected, project=project, version=version) + ) + + # TODO: Reuse the same form for now - maybe we can combine this and the function below adding an automate=True arg + # Render the distribution form + form_html = form.render( + model_cls=shared.distribution.DistributeForm, + submit_label="Distribute", + action=action, + defaults={"package": project, "version": version}, + ) + block.append(form_html) + + return await template.blank(title, content=block.collect()) + + async def _record_form_page(project: str, version: str, staging: bool) -> str: """Helper to render the distribution recording form page.""" await shared.distribution.release_validated(project, version, staging=staging) @@ -126,23 +177,23 @@ async def _record_form_page(project: str, version: str, staging: bool) -> str: block = htm.Block() render.html_nav_phase(block, project, version, staging=staging) - title = "Record a staging distribution" if staging else "Record a manual distribution" + title = "Record a manual staging distribution" if staging else "Record a manual distribution" block.h1[title] block.p[ - "Record a distribution of ", + "Record a manual distribution of ", htm.strong[f"{project}-{version}"], " using the form below.", ] block.p[ "You can also ", - htm.a(href=util.as_url(list_get, project=project, version=version))["view the distribution list"], + htm.a(href=util.as_url(list_get, project_name=project, version_name=version))["view the distribution list"], ".", ] # Determine the action based on staging action = ( - util.as_url(post.distribution.stage_selected, project=project, version=version) + util.as_url(post.distribution.stage_record_selected, project=project, version=version) if staging else util.as_url(post.distribution.record_selected, project=project, version=version) ) diff --git a/atr/get/finish.py b/atr/get/finish.py index 921bf83..3526853 100644 --- a/atr/get/finish.py +++ b/atr/get/finish.py @@ -19,6 +19,7 @@ import dataclasses import json import pathlib +from collections.abc import Sequence import aiofiles.os import asfquart.base as base @@ -42,6 +43,7 @@ import atr.mapping as mapping import atr.models.sql as sql import atr.render as render import atr.shared as shared +import atr.tasks.gha as gha import atr.template as template import atr.util as util import atr.web as web @@ -60,13 +62,9 @@ async def selected( ) -> tuple[web.QuartResponse, int] | web.WerkzeugResponse | str: """Finish a release preview.""" try: - ( - release, - source_files_rel, - target_dirs, - deletable_dirs, - rc_analysis, - ) = await _get_page_data(project_name, version_name) + (release, source_files_rel, target_dirs, deletable_dirs, rc_analysis, tasks) = await _get_page_data( + project_name, version_name + ) except ValueError: async with db.session() as data: release_fallback = await data.release( @@ -89,6 +87,7 @@ async def selected( target_dirs=target_dirs, deletable_dirs=deletable_dirs, rc_analysis=rc_analysis, + distribution_tasks=tasks, ) @@ -134,14 +133,31 @@ async def _deletable_choices( async def _get_page_data( project_name: str, version_name: str -) -> tuple[sql.Release, list[pathlib.Path], set[pathlib.Path], list[tuple[str, str]], RCTagAnalysisResult]: +) -> tuple[ + sql.Release, list[pathlib.Path], set[pathlib.Path], list[tuple[str, str]], RCTagAnalysisResult, Sequence[sql.Task] +]: """Get all the data needed to render the finish page.""" async with db.session() as data: + via = sql.validate_instrumented_attribute release = await data.release( project_name=project_name, version=version_name, _committee=True, ).demand(base.ASFQuartException("Release does not exist", errorcode=404)) + tasks = [ + t + for t in ( + await data.task( + project_name=project_name, + version_name=version_name, + revision_number=release.latest_revision_number, + task_type=sql.TaskType.DISTRIBUTION_WORKFLOW, + ) + .order_by(sql.sqlmodel.desc(via(sql.Task.started))) + .all() + ) + if t.status in [sql.TaskStatus.QUEUED, sql.TaskStatus.ACTIVE, sql.TaskStatus.FAILED] + ] if release.phase != sql.ReleasePhase.RELEASE_PREVIEW: raise ValueError("Release is not in preview phase") @@ -151,7 +167,7 @@ async def _get_page_data( deletable_dirs = await _deletable_choices(latest_revision_dir, target_dirs) rc_analysis_result = await _analyse_rc_tags(latest_revision_dir) - return release, source_files_rel, target_dirs, deletable_dirs, rc_analysis_result + return release, source_files_rel, target_dirs, deletable_dirs, rc_analysis_result, tasks def _render_delete_directory_form(deletable_dirs: list[tuple[str, str]]) -> htm.Element: @@ -247,6 +263,7 @@ async def _render_page( target_dirs: set, deletable_dirs: list[tuple[str, str]], rc_analysis: RCTagAnalysisResult, + distribution_tasks: Sequence[sql.Task], ) -> str: """Render the finish page using htm.py.""" page = htm.Block() @@ -275,8 +292,11 @@ async def _render_page( "such as Maven Central, PyPI, or Docker Hub." ] - # TODO alert - page.append(_render_todo_alert(release)) + if len(distribution_tasks) > 0: + page.append(_render_distribution_tasks(release, distribution_tasks)) + + page.append(_render_dist_warning()) + page.append(_render_distribution_buttons(release)) # Move files section page.append(_render_move_section(max_files_to_show=10)) @@ -401,7 +421,7 @@ def _render_release_card(release: sql.Release) -> htm.Element: version_name=release.version, ), )[ - htpy.i(".bi.bi-download"), + htm.icon("download"), " Download all files", ], htm.a( @@ -413,7 +433,7 @@ def _render_release_card(release: sql.Release) -> htm.Element: version_name=release.version, ), )[ - htpy.i(".bi.bi-archive"), + htm.icon("archive"), " Show files", ], htm.a( @@ -425,7 +445,7 @@ def _render_release_card(release: sql.Release) -> htm.Element: version_name=release.version, ), )[ - htpy.i(".bi.bi-clock-history"), + htm.icon("clock-history"), " Show revisions", ], htm.a( @@ -437,7 +457,7 @@ def _render_release_card(release: sql.Release) -> htm.Element: version_name=release.version, ), )[ - htpy.i(".bi.bi-check-circle"), + htm.icon("check-circle"), " Announce and distribute", ], ], @@ -446,25 +466,83 @@ def _render_release_card(release: sql.Release) -> htm.Element: return card -def _render_todo_alert(release: sql.Release) -> htm.Element: - """Render the TODO alert about distribution tools.""" - return htm.div(".alert.alert-warning.mb-4", role="alert")[ - htm.p(".fw-semibold.mb-1")["TODO"], +def _render_distribution_buttons(release: sql.Release) -> htm.Element: + """Render the distribution tool buttons.""" + return htm.div()[ htm.p(".mb-1")[ - "We plan to add tools to help release managers to distribute release artifacts on distribution networks. " - "Currently you must do this manually. Once you've distributed your release artifacts, you can ", htm.a( + ".btn.btn-primary.me-2", + href=util.as_url( + distribution.automate, + project=release.project.name, + version=release.version, + ), + )["Distribute"], + htm.a( + ".btn.btn-secondary.me-2", href=util.as_url( distribution.record, project=release.project.name, version=release.version, - ) - )["record them on the ATR"], - ".", + ), + )["Record a manual distribution"], + ], + ] + + +def _render_distribution_tasks(release: sql.Release, tasks: Sequence[sql.Task]) -> htm.Element: + """Render current and failed distribution tasks.""" + failed_tasks = [t for t in tasks if t.status == sql.TaskStatus.FAILED] + in_progress_tasks = [t for t in tasks if t.status in [sql.TaskStatus.QUEUED, sql.TaskStatus.ACTIVE]] + + block = htm.Block() + + if len(failed_tasks) > 0: + summary = f"{len(failed_tasks)} distribution{'s' if len(failed_tasks) > 1 else ''} failed for this release" + block.append( + htm.div(".alert.alert-danger.mb-3")[ + htm.h3["Failed distributions"], + htm.details[ + htm.summary[summary], + htm.div[*[_render_task(f) for f in failed_tasks]], + ], + ] + ) + if len(in_progress_tasks) > 0: + block.append( + htm.div(".alert.alert-info.mb-3")[ + htm.h3["In-progress distributions"], + htm.p["One or more automatic distributions are still in-progress:"], + *[_render_task(f) for f in in_progress_tasks], + htm.button( + ".btn.btn-success.me-2", + {"onclick": "window.location.reload()"}, + )["Refresh"], + ] + ) + return block.collect() + + +def _render_dist_warning() -> htm.Element: + """Render the alert about distribution tools.""" + return htm.div(".alert.alert-warning.mb-4", role="alert")[ + htm.p(".fw-semibold.mb-1")["NOTE:"], + htm.p(".mb-1")[ + "Tools to distribute automatically are still being developed, " + "you must do this manually at present. Please use the manual record function below to do so.", ], ] +def _render_task(task: sql.Task) -> htm.Element: + """Render a distribution task's details.""" + args: gha.DistributionWorkflow = gha.DistributionWorkflow.model_validate(task.task_args) + status = task.status.value + return htm.p[ + f"{args.platform} ({args.package} {args.version}): {task.error if task.error else status.capitalize()}" + ] + + async def _sources_and_targets(latest_revision_dir: pathlib.Path) -> tuple[list[pathlib.Path], set[pathlib.Path]]: source_items_rel: list[pathlib.Path] = [] target_dirs: set[pathlib.Path] = {pathlib.Path(".")} diff --git a/atr/models/api.py b/atr/models/api.py index d06c5d3..6df6a14 100644 --- a/atr/models/api.py +++ b/atr/models/api.py @@ -67,6 +67,21 @@ class CommitteesListResults(schema.Strict): committees: Sequence[sql.Committee] +class DistributeSshRegisterArgs(schema.Strict): + publisher: str = schema.example("user") + jwt: str = schema.example("eyJhbGciOiJIUzI1[...]mMjLiuyu5CSpyHI=") + ssh_key: str = schema.example("ssh-ed25519 AAAAC3NzaC1lZDI1NTEgH5C9okWi0dh25AAAAIOMqqnkVzrm0SdG6UOoqKLsabl9GKJl") + phase: str = schema.Field(strict=False, default="compose", json_schema_extra={"examples": ["compose", "finish"]}) + version: str = schema.example("0.0.1") + + +class DistributeSshRegisterResults(schema.Strict): + endpoint: Literal["/distribute/ssh/register"] = schema.alias("endpoint") + fingerprint: str = schema.example("SHA256:0123456789abcdef0123456789abcdef01234567") + project: str = schema.example("example") + expires: int = schema.example(1713547200) + + class DistributionRecordArgs(schema.Strict): project: str = schema.example("example") version: str = schema.example("0.0.1") diff --git a/atr/models/results.py b/atr/models/results.py index 713291d..5dc1ce2 100644 --- a/atr/models/results.py +++ b/atr/models/results.py @@ -24,10 +24,10 @@ import atr.sbom.models.osv as osv from . import schema -class GithubActionsWorkflow(schema.Strict): +class DistributionWorkflow(schema.Strict): """Result of the task to run a Github workflow.""" - kind: Literal["github_actions_workflow"] = schema.Field(alias="kind") + kind: Literal["distribution_workflow"] = schema.Field(alias="kind") name: str = schema.description("The name of the action being performed") run_id: int = schema.description("The ID of the workflow run") url: str = schema.description("The URL of the workflow run") @@ -193,7 +193,7 @@ class MetadataUpdate(schema.Strict): Results = Annotated[ - GithubActionsWorkflow + DistributionWorkflow | HashingCheck | MessageSend | MetadataUpdate diff --git a/atr/models/sql.py b/atr/models/sql.py index 8957123..8938aeb 100644 --- a/atr/models/sql.py +++ b/atr/models/sql.py @@ -53,6 +53,7 @@ sqlmodel.SQLModel.metadata = sqlalchemy.MetaData( @dataclasses.dataclass(frozen=True) class DistributionPlatformValue: name: str + gh_slug: str template_url: str template_staging_url: str | None = None requires_owner_namespace: bool = False @@ -95,12 +96,14 @@ class CheckResultStatusIgnore(str, enum.Enum): class DistributionPlatform(enum.Enum): ARTIFACT_HUB = DistributionPlatformValue( name="Artifact Hub", + gh_slug="artifacthub", template_url="https://artifacthub.io/api/v1/packages/helm/{owner_namespace}/{package}/{version}", template_staging_url="https://staging.artifacthub.io/api/v1/packages/helm/{owner_namespace}/{package}/{version}", requires_owner_namespace=True, ) DOCKER_HUB = DistributionPlatformValue( name="Docker Hub", + gh_slug="dockerhub", template_url="https://hub.docker.com/v2/namespaces/{owner_namespace}/repositories/{package}/tags/{version}", # TODO: Need to use staging tags? # template_staging_url="https://hub.docker.com/v2/namespaces/{owner_namespace}/repositories/{package}/tags/{version}", @@ -108,6 +111,7 @@ class DistributionPlatform(enum.Enum): ) # GITHUB = DistributionPlatformValue( # name="GitHub", + # gh_slug="github", # template_url="https://api.github.com/repos/{owner_namespace}/{package}/releases/tags/v{version}", # # Combine with {"prerelease": true} # template_staging_url="https://api.github.com/repos/{owner_namespace}/{package}/releases", @@ -115,6 +119,7 @@ class DistributionPlatform(enum.Enum): # ) MAVEN = DistributionPlatformValue( name="Maven Central", + gh_slug="maven", template_url="https://search.maven.org/solrsearch/select?q=g:{owner_namespace}+AND+a:{package}+AND+v:{version}&core=gav&rows=20&wt=json", # Java ASF projects use staging URLs along the lines of # https://repository.apache.org/content/repositories/orgapachePROJECT-NNNN/ @@ -123,17 +128,20 @@ class DistributionPlatform(enum.Enum): ) NPM = DistributionPlatformValue( name="npm", + gh_slug="npm", # TODO: Need to parse dist-tags template_url="https://registry.npmjs.org/{package}", ) NPM_SCOPED = DistributionPlatformValue( name="npm (scoped)", + gh_slug="npm", # TODO: Need to parse dist-tags template_url="https://registry.npmjs.org/@{owner_namespace}/{package}", requires_owner_namespace=True, ) PYPI = DistributionPlatformValue( name="PyPI", + gh_slug="pypi", template_url="https://pypi.org/pypi/{package}/{version}/json", template_staging_url="https://test.pypi.org/pypi/{package}/{version}/json", ) @@ -179,7 +187,7 @@ class TaskStatus(str, enum.Enum): class TaskType(str, enum.Enum): - GITHUB_ACTION_WORKFLOW = "github_action_workflow" + DISTRIBUTION_WORKFLOW = "distribution_workflow" HASHING_CHECK = "hashing_check" KEYS_IMPORT_FILE = "keys_import_file" LICENSE_FILES = "license_files" diff --git a/atr/post/distribution.py b/atr/post/distribution.py index 15fa395..c5f3428 100644 --- a/atr/post/distribution.py +++ b/atr/post/distribution.py @@ -25,6 +25,9 @@ import atr.shared as shared import atr.storage as storage import atr.web as web +_AUTOMATED_PLATFORMS = [shared.distribution.DistributionPlatform.MAVEN] +_AUTOMATED_PLATFORMS_STAGE = [shared.distribution.DistributionPlatform.MAVEN] + @post.committer("/distribution/delete/<project>/<version>") @post.form(shared.distribution.DeleteForm) @@ -53,12 +56,84 @@ async def delete( ) return await session.redirect( get.distribution.list_get, - project=project, - version=version, + project_name=project, + version_name=version, success="Distribution deleted", ) +async def automate_form_process_page( + session: web.Committer, + form_data: shared.distribution.DistributeForm, + project: str, + version: str, + /, + staging: bool = False, +) -> web.WerkzeugResponse: + allowed_platforms = _AUTOMATED_PLATFORMS_STAGE if staging else _AUTOMATED_PLATFORMS + if form_data.platform not in allowed_platforms: + platform_str = form_data.platform.value + return await session.redirect( + get.distribution.stage_automate if staging else get.distribution.automate, + project=project, + version=version, + error=f"Platform {platform_str} is not supported for automated distribution", + ) + sql_platform = form_data.platform.to_sql() # type: ignore[attr-defined] + dd = distribution.Data( + platform=sql_platform, + owner_namespace=form_data.owner_namespace, + package=form_data.package, + version=form_data.version, + details=form_data.details, + ) + release, committee = await shared.distribution.release_validated_and_committee( + project, version, staging=staging, release_policy=True + ) + if release.release_policy is None or release.release_policy.github_repository_name == "": + return await session.redirect( + get.distribution.stage_automate if staging else get.distribution.automate, + project=project, + version=version, + error="Project does not have a release policy configured, or no GitHub repository is specified", + ) + repo = release.release_policy.github_repository_name + workflow = f"distribute-{sql_platform.value.gh_slug}.yml" + async with storage.write_as_committee_member(committee_name=committee.name) as w: + try: + await w.distributions.automate( + release.name, + dd.platform, + dd.owner_namespace, + "apache", + repo, + workflow, + project, + version, + release.latest_revision_number, + dd.package, + dd.version, + staging, + ) + except storage.AccessError as e: + # Instead of calling record_form_page_new, redirect with error message + return await session.redirect( + get.distribution.stage_automate if staging else get.distribution.automate, + project=project, + version=version, + error=str(e), + ) + + # Success - redirect to distribution list with success message + message = "Distribution queued successfully." + return await session.redirect( + get.distribution.list_get if staging else get.finish.selected, + project_name=project, + version_name=version, + success=message, + ) + + async def record_form_process_page( session: web.Committer, form_data: shared.distribution.DistributeForm, @@ -84,14 +159,14 @@ async def record_form_process_page( async with storage.write_as_committee_member(committee_name=committee.name) as w: try: _dist, added, _metadata = await w.distributions.record_from_data( - release=release, + release=release.name, staging=staging, dd=dd, ) except storage.AccessError as e: # Instead of calling record_form_page_new, redirect with error message return await session.redirect( - get.distribution.stage if staging else get.distribution.record, + get.distribution.stage_record if staging else get.distribution.record, project=project, version=version, error=str(e), @@ -101,12 +176,28 @@ async def record_form_process_page( message = "Distribution recorded successfully." if added else "Distribution was already recorded." return await session.redirect( get.distribution.list_get, - project=project, - version=version, + project_name=project, + version_name=version, success=message, ) [email protected]("/distribution/automate/<project>/<version>") [email protected](shared.distribution.DistributeForm) +async def automate_selected( + session: web.Committer, distribute_form: shared.distribution.DistributeForm, project: str, version: str +) -> web.WerkzeugResponse: + return await automate_form_process_page(session, distribute_form, project, version, staging=False) + + [email protected]("/distribution/stage/automate/<project>/<version>") [email protected](shared.distribution.DistributeForm) +async def stage_automate_selected( + session: web.Committer, distribute_form: shared.distribution.DistributeForm, project: str, version: str +) -> web.WerkzeugResponse: + return await automate_form_process_page(session, distribute_form, project, version, staging=True) + + @post.committer("/distribution/record/<project>/<version>") @post.form(shared.distribution.DistributeForm) async def record_selected( @@ -115,9 +206,9 @@ async def record_selected( return await record_form_process_page(session, distribute_form, project, version, staging=False) [email protected]("/distribution/stage/<project>/<version>") [email protected]("/distribution/stage/record/<project>/<version>") @post.form(shared.distribution.DistributeForm) -async def stage_selected( +async def stage_record_selected( session: web.Committer, distribute_form: shared.distribution.DistributeForm, project: str, version: str ) -> web.WerkzeugResponse: return await record_form_process_page(session, distribute_form, project, version, staging=True) diff --git a/atr/server.py b/atr/server.py index e39ffff..3f13739 100644 --- a/atr/server.py +++ b/atr/server.py @@ -335,9 +335,10 @@ def _app_setup_security_headers(app: base.QuartApp) -> None: # Both object-src 'none' and base-uri 'none' are required by ASVS v5 3.4.3 (L2) # The frame-ancestors 'none' directive is required by ASVS v5 3.4.6 (L2) # Bootstrap uses data: URLs extensively, so we need to include that in img-src + # The script hash allows window.location.reload() and nothing else csp_directives = [ "default-src 'self'", - "script-src 'self'", + "script-src 'self' 'sha256-4TpZ3Tx5SLybDXPQaSHGuP1RU4D+pzck+02JLVY61BY=' 'unsafe-hashes'", "style-src 'self' 'unsafe-inline'", "img-src 'self' https://apache.org https://incubator.apache.org https://www.apache.org data:", "font-src 'self'", diff --git a/atr/shared/distribution.py b/atr/shared/distribution.py index 444d066..743c781 100644 --- a/atr/shared/distribution.py +++ b/atr/shared/distribution.py @@ -132,10 +132,7 @@ def html_tr_a(label: str, value: str | None) -> htm.Element: async def release_validated( - project: str, - version: str, - committee: bool = False, - staging: bool | None = None, + project: str, version: str, committee: bool = False, staging: bool | None = None, release_policy: bool = False ) -> sql.Release: match staging: case True: @@ -149,6 +146,7 @@ async def release_validated( project_name=project, version=version, _committee=committee, + _release_policy=release_policy, ).demand(RuntimeError(f"Release {project} {version} not found")) if release.phase not in phase: raise RuntimeError(f"Release {project} {version} is not in {phase}") @@ -158,12 +156,9 @@ async def release_validated( async def release_validated_and_committee( - project: str, - version: str, - *, - staging: bool | None = None, + project: str, version: str, *, staging: bool | None = None, release_policy: bool = False ) -> tuple[sql.Release, sql.Committee]: - release = await release_validated(project, version, committee=True, staging=staging) + release = await release_validated(project, version, committee=True, staging=staging, release_policy=release_policy) committee = release.committee if committee is None: raise RuntimeError(f"Release {project} {version} has no committee") diff --git a/atr/storage/writers/distributions.py b/atr/storage/writers/distributions.py index 2db5d85..a9efd84 100644 --- a/atr/storage/writers/distributions.py +++ b/atr/storage/writers/distributions.py @@ -31,6 +31,8 @@ import atr.models.distribution as distribution import atr.models.sql as sql import atr.storage as storage import atr.storage.outcome as outcome +import atr.tasks.gha as gha +import atr.util as util class GeneralPublic: @@ -95,6 +97,50 @@ class CommitteeMember(CommitteeParticipant): self.__asf_uid = asf_uid self.__committee_name = committee_name + async def automate( + self, + release_name: str, + platform: sql.DistributionPlatform, + owner_namespace: str | None, + owner: str, + repo: str, + workflow: str, + project_name: str, + version_name: str, + revision_number: str | None, + package: str, + version: str, + staging: bool, + ) -> sql.Task: + dist_task = sql.Task( + task_type=sql.TaskType.DISTRIBUTION_WORKFLOW, + task_args=gha.DistributionWorkflow( + name=release_name, + namespace=owner_namespace or "", + package=package, + version=version, + project_name=project_name, + version_name=version_name, + platform=platform.name, + owner=owner, + repo=repo, + ref="main", # TODO: Un-hardcode + workflow=workflow, + staging=staging, + arguments={}, + ).model_dump(), + asf_uid=util.unwrap(self.__asf_uid), + added=datetime.datetime.now(datetime.UTC), + status=sql.TaskStatus.QUEUED, + project_name=project_name, + version_name=version_name, + revision_number=revision_number, + ) + self.__data.add(dist_task) + await self.__data.commit() + await self.__data.refresh(dist_task) + return dist_task + async def record( self, release_name: str, @@ -148,7 +194,7 @@ class CommitteeMember(CommitteeParticipant): async def record_from_data( self, - release: sql.Release, + release: str, staging: bool, dd: distribution.Data, ) -> tuple[sql.Distribution, bool, distribution.Metadata]: @@ -176,7 +222,7 @@ class CommitteeMember(CommitteeParticipant): web_url=web_url, ) dist, added = await self.record( - release_name=release.name, + release_name=release, platform=dd.platform, owner_namespace=dd.owner_namespace, package=dd.package, diff --git a/atr/tasks/__init__.py b/atr/tasks/__init__.py index dde92ed..9d89e31 100644 --- a/atr/tasks/__init__.py +++ b/atr/tasks/__init__.py @@ -189,7 +189,7 @@ def queued( def resolve(task_type: sql.TaskType) -> Callable[..., Awaitable[results.Results | None]]: # noqa: C901 match task_type: - case sql.TaskType.GITHUB_ACTION_WORKFLOW: + case sql.TaskType.DISTRIBUTION_WORKFLOW: return gha.trigger_workflow case sql.TaskType.HASHING_CHECK: return hashing.check diff --git a/atr/tasks/gha.py b/atr/tasks/gha.py index f64b2cc..7b9123f 100644 --- a/atr/tasks/gha.py +++ b/atr/tasks/gha.py @@ -24,48 +24,79 @@ import aiohttp import atr.config as config import atr.log as log +import atr.models.distribution as distribution import atr.models.results as results import atr.models.schema as schema +import atr.models.sql as sql + +# import atr.shared as shared +import atr.storage as storage import atr.tasks.checks as checks _BASE_URL: Final[str] = "https://api.github.com/repos" _IN_PROGRESS_STATUSES: Final[list[str]] = ["in_progress", "queued", "requested", "waiting", "pending", "expected"] _COMPLETED_STATUSES: Final[list[str]] = ["completed"] _FAILED_STATUSES: Final[list[str]] = ["failure", "startup_failure"] -_TIMEOUT_S = 5 +_TIMEOUT_S = 60 -class GithubActionsWorkflow(schema.Strict): +class DistributionWorkflow(schema.Strict): """Arguments for the task to start a Github Actions workflow.""" owner: str = schema.description("Github owner of the repository") repo: str = schema.description("Repository in which to start the workflow") - workflow_id: str = schema.description("Workflow ID") ref: str = schema.description("Git ref to trigger the workflow") + workflow: str = schema.description("Workflow to trigger") + namespace: str = schema.description("Namespace to distribute to") + package: str = schema.description("Package to distribute") + version: str = schema.description("Version to distribute") + staging: bool = schema.description("Whether this is a staging distribution") + project_name: str = schema.description("Project name in ATR") + version_name: str = schema.description("Version name in ATR") + platform: str = schema.description("Distribution platform") arguments: dict[str, str] = schema.description("Workflow arguments") name: str = schema.description("Name of the run") [email protected]_model(GithubActionsWorkflow) -async def trigger_workflow(args: GithubActionsWorkflow) -> results.Results | None: [email protected]_model(DistributionWorkflow) +async def trigger_workflow(args: DistributionWorkflow) -> results.Results | None: unique_id = f"{args.name}-{uuid.uuid4()}" - payload = {"ref": args.ref, "inputs": {"atr-id": unique_id, **args.arguments}} + # release, committee = await shared.distribution.release_validated_and_committee( + # args.project, + # args.version, + # staging=True, # TODO: Un-hardcode + # ) + try: + sql_platform = sql.DistributionPlatform[args.platform] + except KeyError: + _fail(f"Invalid platform: {args.platform}") + payload = { + "ref": args.ref, + "inputs": { + "atr-id": unique_id, + "platform": args.platform, + "distribution-package": args.package, + "distribution-version": args.version, + "staging": "true" if args.staging else "false", + **args.arguments, + }, + } headers = {"Accept": "application/vnd.github+json", "Authorization": f"Bearer {config.get().GITHUB_TOKEN}"} log.info( - f"Triggering Github workflow {args.owner}/{args.repo}/{args.workflow_id} with args: { + f"Triggering Github workflow {args.owner}/{args.repo}/{args.workflow} with args: { json.dumps(args.arguments, indent=2) }" ) async with aiohttp.ClientSession() as session: try: async with session.post( - f"{_BASE_URL}/{args.owner}/{args.repo}/actions/workflows/{args.workflow_id}/dispatches", + f"{_BASE_URL}/{args.owner}/{args.repo}/actions/workflows/{args.workflow}/dispatches", headers=headers, json=payload, ) as response: response.raise_for_status() except aiohttp.ClientResponseError as e: - _fail(f"Failed to trigger workflow run: {e.message} ({e.status})") + _fail(f"Failed to trigger GitHub workflow: {e.message} ({e.status})") run, run_id = await _find_triggered_run(session, args, headers, unique_id) @@ -73,13 +104,46 @@ async def trigger_workflow(args: GithubActionsWorkflow) -> results.Results | Non run = await _wait_for_completion(session, args, headers, run_id, unique_id) if run.get("status") in _FAILED_STATUSES: - _fail(f"Github workflow {args.owner}/{args.repo}/{args.workflow_id} run {run_id} failed with error") + _fail(f"Github workflow {args.owner}/{args.repo}/{args.workflow} run {run_id} failed with error") if run.get("status") in _COMPLETED_STATUSES: - log.info(f"Workflow {args.owner}/{args.repo}/{args.workflow_id} run {run_id} completed successfully") - return results.GithubActionsWorkflow( - kind="github_actions_workflow", name=args.name, run_id=run_id, url=run.get("html_url", "") + log.info(f"Workflow {args.owner}/{args.repo}/{args.workflow} run {run_id} completed successfully") + await _record_distribution( + "committee.name", + "release", + sql_platform, + namespace=args.namespace, + package=args.package, + version=args.version, + staging=True, + ) + return results.DistributionWorkflow( + kind="distribution_workflow", name=args.name, run_id=run_id, url=run.get("html_url", "") ) - _fail(f"Timed out waiting for workflow {args.owner}/{args.repo}/{args.workflow_id}") + _fail(f"Timed out waiting for GitHub workflow {args.owner}/{args.repo}/{args.workflow}") + + +async def _record_distribution( + committee_name: str, + release: str, + platform: sql.DistributionPlatform, + namespace: str, + package: str, + version: str, + staging: bool, +): + log.info("Creating distribution record") + dd = distribution.Data( + platform=platform, + owner_namespace=namespace, + package=package, + version=version, + details=False, + ) + async with storage.write_as_committee_member(committee_name=committee_name) as w: + try: + _dist, _added, _metadata = await w.distributions.record_from_data(release=release, staging=staging, dd=dd) + except storage.AccessError as e: + _fail(f"Failed to record distribution: {e}") def _fail(message: str) -> NoReturn: @@ -89,7 +153,7 @@ def _fail(message: str) -> NoReturn: async def _find_triggered_run( session: aiohttp.ClientSession, - args: GithubActionsWorkflow, + args: DistributionWorkflow, headers: dict[str, str], unique_id: str, ) -> tuple[dict[str, Any], int]: @@ -140,7 +204,7 @@ async def _request_and_retry( async def _wait_for_completion( session: aiohttp.ClientSession, - args: GithubActionsWorkflow, + args: DistributionWorkflow, headers: dict[str, str], run_id: int, unique_id: str, diff --git a/atr/templates/check-selected.html b/atr/templates/check-selected.html index 0bb1534..77b793d 100644 --- a/atr/templates/check-selected.html +++ b/atr/templates/check-selected.html @@ -125,9 +125,19 @@ </div> </div> {% if phase == "release_candidate_draft" %} + <h3 id="distribution" class="mt-4">Distribution</h3> + <p> + While this release is in draft, you can create a staging distribution. Use the buttons below to either create one automatically (where supported) or record a manual distribution performed outside of ATR. + </p> + <div class="alert alert-warning mb-4"> + <p class="fw-semibold mb-1">NOTE:</p> + <p>At present, automated distributions are being developed. Please use the manual record button if you need to record a distribution.</p> + </div> <p> <a class="btn btn-primary" - href="{{ as_url(get.distribution.stage, project=release.project.name, version=release.version) }}">Record a distribution</a> + href="{{ as_url(get.distribution.stage_automate, project=release.project.name, version=release.version) }}">Distribute</a> + <a class="btn btn-secondary" + href="{{ as_url(get.distribution.stage_record, project=release.project.name, version=release.version) }}">Record a manual distribution</a> </p> <h2 id="more-actions">More actions</h2> <h3 id="ignored-checks" class="mt-4">Ignored checks</h3> --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
