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-atr-experiments.git
The following commit(s) were added to refs/heads/main by this push: new b8b196a Improve the release candidate workflow b8b196a is described below commit b8b196a76bf66edab2d72eca1c6b306f950fa3e3 Author: Sean B. Palmer <s...@miscoranda.com> AuthorDate: Wed Feb 19 19:24:54 2025 +0200 Improve the release candidate workflow --- atr/config.py | 2 - atr/db/__init__.py | 4 +- atr/db/models.py | 15 +- atr/routes.py | 143 +++++++----- atr/static/css/atr.css | 6 + .../{release-attach.html => candidate-attach.html} | 10 +- .../{release-create.html => candidate-create.html} | 0 atr/templates/candidate-review.html | 163 ++++++++++++++ ...verify.html => candidate-signature-verify.html} | 86 +++++--- atr/templates/includes/sidebar.html | 22 +- atr/templates/index.html | 11 +- .../{user-keys-add.html => keys-add.html} | 134 +----------- atr/templates/keys-review.html | 141 ++++++++++++ atr/templates/pages.html | 240 --------------------- atr/templates/user-uploads.html | 103 --------- ...al_schema.py => b561e6142755_initial_schema.py} | 6 +- 16 files changed, 503 insertions(+), 583 deletions(-) diff --git a/atr/config.py b/atr/config.py index 7a3c11d..8da79a7 100644 --- a/atr/config.py +++ b/atr/config.py @@ -23,14 +23,12 @@ from atr.db.models import __file__ as data_models_file class AppConfig: - # Get the project root directory (where alembic.ini is) PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) STATE_DIR = os.path.join(PROJECT_ROOT, "state") RELEASE_STORAGE_DIR = os.path.join(STATE_DIR, "releases") DATA_MODELS_FILE = data_models_file - # Use aiosqlite for async SQLite access SQLITE_URL = config("SQLITE_URL", default="sqlite+aiosqlite:///./atr.db") ADMIN_USERS = frozenset( diff --git a/atr/db/__init__.py b/atr/db/__init__.py index 21e200e..003b436 100644 --- a/atr/db/__init__.py +++ b/atr/db/__init__.py @@ -17,7 +17,7 @@ import os -from alembic import command +# from alembic import command from alembic.config import Config from quart import current_app from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine @@ -66,7 +66,7 @@ def create_database(app: QuartApp) -> None: alembic_cfg.set_main_option("script_location", os.path.join(project_root, "migrations")) # Set the database URL in the config alembic_cfg.set_main_option("sqlalchemy.url", sqlite_url) - command.upgrade(alembic_cfg, "head") + # command.upgrade(alembic_cfg, "head") # Create any tables that might be missing async with engine.begin() as conn: diff --git a/atr/db/models.py b/atr/db/models.py index 6b27006..639f509 100644 --- a/atr/db/models.py +++ b/atr/db/models.py @@ -134,10 +134,16 @@ class DistributionChannel(SQLModel, table=True): class Package(SQLModel, table=True): - id: int | None = Field(default=None, primary_key=True) - file: str - signature: str - checksum: str + # The SHA3-256 hash of the file, used as filename in storage + id_sha3: str = Field(primary_key=True) + # Original filename from uploader + filename: str + # SHA-512 hash of the file + sha512: str + # The signature file + signature_sha3: str + # Uploaded timestamp + uploaded: datetime.datetime # Many-to-one: A package belongs to one release release_key: str | None = Field(default=None, foreign_key="release.storage_key") @@ -179,6 +185,7 @@ class Release(SQLModel, table=True): storage_key: str = Field(primary_key=True) stage: ReleaseStage phase: ReleasePhase + created: datetime.datetime # Many-to-one: A release belongs to one PMC, a PMC can have multiple releases pmc_id: int | None = Field(default=None, foreign_key="pmc.id") diff --git a/atr/routes.py b/atr/routes.py index c37c9c3..bb4fc12 100644 --- a/atr/routes.py +++ b/atr/routes.py @@ -125,28 +125,44 @@ async def release_attach_post(session: ClientSession, request: Request) -> Respo # Save files using their hashes as filenames uploads_path = Path(get_release_storage_dir()) - artifact_hash = await save_file_by_hash(uploads_path, artifact_file) - # TODO: Do we need to do anything with the signature hash? - # These should be identical, but path might be absolute? - # TODO: Need to check, ideally. Could have a data browser - signature_hash = await save_file_by_hash(uploads_path, signature_file) + artifact_sha3 = await save_file_by_hash(uploads_path, artifact_file) + signature_sha3 = await save_file_by_hash(uploads_path, signature_file) - # Compute SHA-512 checksum of the artifact for the package record - checksum_512 = compute_sha512(uploads_path / artifact_hash) + # Check if these files are already attached to this release + async with get_session() as db_session: + # Check for duplicate artifact or signature in a single query + statement = select(Package).where( + Package.release_key == release_key, + (Package.id_sha3 == artifact_sha3) | (Package.signature_sha3 == signature_sha3), + ) + duplicate = (await db_session.execute(statement)).first() + + if duplicate: + package = duplicate[0] + # TODO: Perhaps we should call the id_sha3 field artifact_sha3 instead + if package.id_sha3 == artifact_sha3: + raise ASFQuartException("This release artifact has already been uploaded", errorcode=400) + else: + raise ASFQuartException("This signature file has already been uploaded", errorcode=400) + + # Compute SHA-512 of the artifact for the package record + sha512 = compute_sha512(uploads_path / artifact_sha3) # Create the package record in the database async with get_session() as db_session: async with db_session.begin(): package = Package( - file=artifact_hash, - signature=signature_hash, - checksum=checksum_512, + id_sha3=artifact_sha3, + filename=artifact_file.filename, + signature_sha3=signature_sha3, + sha512=sha512, release_key=release_key, + uploaded=datetime.datetime.now(datetime.UTC), ) db_session.add(package) - # Redirect to the user's uploads page - return redirect(url_for("root_user_uploads")) + # Redirect to the release candidate review page + return redirect(url_for("root_candidate_review")) async def release_create_post(session: ClientSession, request: Request) -> Response: @@ -193,6 +209,7 @@ async def release_create_post(session: ClientSession, request: Request) -> Respo phase=ReleasePhase.RELEASE_CANDIDATE, pmc_id=pmc.id, version=version, + created=datetime.datetime.now(datetime.UTC), ) db_session.add(release) @@ -202,7 +219,7 @@ async def release_create_post(session: ClientSession, request: Request) -> Respo # Redirect to the attach artifacts page with the storage token # We should possibly have a results, or list of releases, page instead - return redirect(url_for("root_release_attach", storage_key=storage_token)) + return redirect(url_for("root_candidate_attach", storage_key=storage_token)) @APP.route("/") @@ -211,15 +228,9 @@ async def root() -> str: return await render_template("index.html") -@APP.route("/pages") -async def root_pages() -> str: - """List all pages on the website.""" - return await render_template("pages.html") - - -@APP.route("/release/attach", methods=["GET", "POST"]) +@APP.route("/candidate/attach", methods=["GET", "POST"]) @require(Requirements.committer) -async def root_release_attach() -> Response | str: +async def root_candidate_attach() -> Response | str: """Attach package artifacts to an existing release.""" session = await session_read() if session is None: @@ -249,16 +260,16 @@ async def root_release_attach() -> Response | str: # For GET requests, show the form return await render_template( - "release-attach.html", + "candidate-attach.html", asf_id=session.uid, releases=user_releases, selected_release=storage_key, ) -@APP.route("/release/create", methods=["GET", "POST"]) +@APP.route("/candidate/create", methods=["GET", "POST"]) @require(Requirements.committer) -async def root_release_create() -> Response | str: +async def root_candidate_create() -> Response | str: """Create a new release in the database.""" session = await session_read() if session is None: @@ -270,16 +281,16 @@ async def root_release_create() -> Response | str: # For GET requests, show the form return await render_template( - "release-create.html", + "candidate-create.html", asf_id=session.uid, pmc_memberships=session.committees, committer_projects=session.projects, ) -@APP.route("/release/signatures/verify/<release_key>") +@APP.route("/candidate/signatures/verify/<release_key>") @require(Requirements.committer) -async def root_release_signatures_verify(release_key: str) -> str: +async def root_candidate_signatures_verify(release_key: str) -> str: """Verify the GPG signatures for all packages in a release candidate.""" session = await session_read() if session is None: @@ -314,10 +325,10 @@ async def root_release_signatures_verify(release_key: str) -> str: storage_dir = Path(get_release_storage_dir()) for package in release.packages: - result = {"file": package.file} + result = {"file": package.id_sha3} - artifact_path = storage_dir / package.file - signature_path = storage_dir / package.signature + artifact_path = storage_dir / package.id_sha3 + signature_path = storage_dir / package.signature_sha3 if not artifact_path.exists(): result["error"] = "Package artifact file not found" @@ -326,12 +337,12 @@ async def root_release_signatures_verify(release_key: str) -> str: else: # Verify the signature result = await verify_gpg_signature(artifact_path, signature_path, ascii_armored_keys) - result["file"] = package.file + result["file"] = package.id_sha3 verification_results.append(result) return await render_template( - "release-signature-verify.html", release=release, verification_results=verification_results + "candidate-signature-verify.html", release=release, verification_results=verification_results ) @@ -405,24 +416,39 @@ async def root_pmc_list() -> list[dict]: ] -@APP.route("/user/keys/add", methods=["GET", "POST"]) +@APP.route("/keys/review") @require(Requirements.committer) -async def root_user_keys_add() -> str: - """Add a new GPG key to the user's account.""" +async def root_keys_review() -> str: + """Show all GPG keys associated with the user's account.""" session = await session_read() if session is None: raise ASFQuartException("Not authenticated", errorcode=401) - error = None - key_info = None - user_keys = [] - # Get all existing keys for the user async with get_session() as db_session: pmcs_loader = selectinload(cast(InstrumentedAttribute[list[PMC]], PublicSigningKey.pmcs)) statement = select(PublicSigningKey).options(pmcs_loader).where(PublicSigningKey.apache_uid == session.uid) user_keys = (await db_session.execute(statement)).scalars().all() + return await render_template( + "keys-review.html", + asf_id=session.uid, + user_keys=user_keys, + algorithms=algorithms, + ) + + +@APP.route("/keys/add", methods=["GET", "POST"]) +@require(Requirements.committer) +async def root_keys_add() -> str: + """Add a new GPG key to the user's account.""" + session = await session_read() + if session is None: + raise ASFQuartException("Not authenticated", errorcode=401) + + error = None + key_info = None + if request.method == "POST": form = await request.form public_key = form.get("public_key") @@ -434,26 +460,26 @@ async def root_user_keys_add() -> str: selected_pmcs = form.getlist("selected_pmcs") if not selected_pmcs: return await render_template( - "user-keys-add.html", + "keys-add.html", asf_id=session.uid, pmc_memberships=session.committees, error="You must select at least one PMC", key_info=None, - user_keys=user_keys, algorithms=algorithms, committer_projects=session.projects, ) # Ensure that the selected PMCs are ones of which the user is actually a member - invalid_pmcs = [pmc for pmc in selected_pmcs if pmc not in session.committees] + invalid_pmcs = [ + pmc for pmc in selected_pmcs if (pmc not in session.committees) and (pmc not in session.projects) + ] if invalid_pmcs: return await render_template( - "user-keys-add.html", + "keys-add.html", asf_id=session.uid, pmc_memberships=session.committees, error=f"Invalid PMC selection: {', '.join(invalid_pmcs)}", key_info=None, - user_keys=user_keys, algorithms=algorithms, committer_projects=session.projects, ) @@ -461,12 +487,11 @@ async def root_user_keys_add() -> str: error, key_info = await user_keys_add(session, public_key, selected_pmcs) return await render_template( - "user-keys-add.html", + "keys-add.html", asf_id=session.uid, pmc_memberships=session.committees, error=error, key_info=key_info, - user_keys=user_keys, algorithms=algorithms, committer_projects=session.projects, ) @@ -495,10 +520,10 @@ async def root_user_keys_delete() -> str: return f"Deleted {count} keys" -@APP.route("/user/uploads") +@APP.route("/candidate/review") @require(Requirements.committer) -async def root_user_uploads() -> str: - """Show all release candidates uploaded by the current user.""" +async def root_candidate_review() -> str: + """Show all release candidates to which the user has access.""" session = await session_read() if session is None: raise ASFQuartException("Not authenticated", errorcode=401) @@ -525,7 +550,7 @@ async def root_user_uploads() -> str: if session.uid in r.pmc.pmc_members: user_releases.append(r) - return await render_template("user-uploads.html", releases=user_releases) + return await render_template("candidate-review.html", releases=user_releases) async def save_file_by_hash(base_dir: Path, file: FileStorage) -> str: @@ -579,7 +604,9 @@ async def user_keys_add(session: ClientSession, public_key: str, selected_pmcs: if not import_result.fingerprints: return ("Invalid public key format", None) - fingerprint = import_result.fingerprints[0].lower() + fingerprint = import_result.fingerprints[0] + if fingerprint is not None: + fingerprint = fingerprint.lower() # APP.logger.info("Import result: %s", vars(import_result)) # Get key details # We could probably use import_result instead @@ -589,7 +616,7 @@ async def user_keys_add(session: ClientSession, public_key: str, selected_pmcs: # Then we have the properties listed here: # https://gnupg.readthedocs.io/en/latest/#listing-keys # Note that "fingerprint" is not listed there, but we have it anyway... - key = next((k for k in keys if k["fingerprint"].lower() == fingerprint), None) + key = next((k for k in keys if (k["fingerprint"] is not None) and (k["fingerprint"].lower() == fingerprint)), None) if not key: return ("Failed to import key", None) if (key.get("algo") == "1") and (int(key.get("length", "0")) < 2048): @@ -620,11 +647,15 @@ async def user_keys_add_session( if not session.uid: return ("You must be signed in to add a key", None) + fingerprint = key.get("fingerprint") + if not isinstance(fingerprint, str): + return ("Invalid key fingerprint", None) + fingerprint = fingerprint.lower() uids = key.get("uids") async with db_session.begin(): # Create new key record key_record = PublicSigningKey( - fingerprint=key["fingerprint"].lower(), + fingerprint=fingerprint, algorithm=int(key["algo"]), length=int(key.get("length", "0")), created=datetime.datetime.fromtimestamp(int(key["date"])), @@ -650,7 +681,7 @@ async def user_keys_add_session( "", { "key_id": key["keyid"], - "fingerprint": key["fingerprint"].lower(), + "fingerprint": key["fingerprint"].lower() if key.get("fingerprint") else "Unknown", "user_id": key["uids"][0] if key.get("uids") else "Unknown", "creation_date": datetime.datetime.fromtimestamp(int(key["date"])), "expiration_date": datetime.datetime.fromtimestamp(int(key["expires"])) if key.get("expires") else None, @@ -694,8 +725,8 @@ async def verify_gpg_signature_file( # Collect all available information for debugging debug_info = { "key_id": verified.key_id or "Not available", - "fingerprint": verified.fingerprint.lower() or "Not available", - "pubkey_fingerprint": verified.pubkey_fingerprint.lower() or "Not available", + "fingerprint": verified.fingerprint.lower() if verified.fingerprint else "Not available", + "pubkey_fingerprint": verified.pubkey_fingerprint.lower() if verified.pubkey_fingerprint else "Not available", "creation_date": verified.creation_date or "Not available", "timestamp": verified.timestamp or "Not available", "username": verified.username or "Not available", diff --git a/atr/static/css/atr.css b/atr/static/css/atr.css index 5aca184..6625b04 100644 --- a/atr/static/css/atr.css +++ b/atr/static/css/atr.css @@ -81,9 +81,15 @@ table td { /* Not sure if we should keep it this way, but it seems pretty good */ font-family: ui-monospace, "SFMono-Regular", "Menlo", "Monaco", "Consolas", monospace; + word-break: break-all; font-size: 0.9em; } +table td.prose { + font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "Oxygen", "Ubuntu", "Cantarell", "Open Sans", "Helvetica Neue", sans-serif; + font-size: 1em; +} + table tr { /* This doesn't always work; not clear why */ border-bottom: 1px solid #c1c2c3; diff --git a/atr/templates/release-attach.html b/atr/templates/candidate-attach.html similarity index 93% rename from atr/templates/release-attach.html rename to atr/templates/candidate-attach.html index 082a784..b304560 100644 --- a/atr/templates/release-attach.html +++ b/atr/templates/candidate-attach.html @@ -64,7 +64,8 @@ {% block content %} <h1>Attach package artifacts</h1> <p class="intro"> - Welcome, <strong>{{ asf_id }}</strong>! Use this form to attach package artifacts to an existing release candidate. + Welcome, <strong>{{ asf_id }}</strong>! Use this form to attach package artifacts + to an existing release candidate. </p> <form method="post" enctype="multipart/form-data" class="striking"> @@ -84,7 +85,12 @@ </option> {% endfor %} </select> - {% if not releases %}<p class="error-message">No releases found that you can attach artifacts to.</p>{% endif %} + {% if not releases %} + <p class="error-message"> + No releases found that you can + attach artifacts to. + </p> + {% endif %} </td> </tr> diff --git a/atr/templates/release-create.html b/atr/templates/candidate-create.html similarity index 100% rename from atr/templates/release-create.html rename to atr/templates/candidate-create.html diff --git a/atr/templates/candidate-review.html b/atr/templates/candidate-review.html new file mode 100644 index 0000000..40f3e5a --- /dev/null +++ b/atr/templates/candidate-review.html @@ -0,0 +1,163 @@ +{% extends "layouts/base.html" %} + +{% block title %} + Release candidates ~ ATR +{% endblock title %} + +{% block description %} + Release candidates to which you have access. +{% endblock description %} + +{% block stylesheets %} + {{ super() }} + <style> + .candidate-table { + width: 100%; + border-collapse: collapse; + margin: 1rem 0; + } + + .candidate-table th, + .candidate-table td { + padding: 0.75rem; + text-align: left; + border: 1px solid #ddd; + } + + .candidate-table th { + background-color: #f5f5f5; + font-weight: 600; + width: 200px; + } + + .candidate-table tr:hover { + background-color: #f8f8f8; + } + + .candidate-meta { + color: #666; + font-size: 0.9em; + } + + .no-releases { + color: #666; + font-style: italic; + } + + .verify-link { + display: inline-block; + padding: 0.5rem 1rem; + background: #003366; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + font-weight: 500; + text-decoration: none; + } + + .verify-link:hover { + background: #004477; + color: white; + } + + .package-separator { + height: 2rem; + background-color: #f5f5f5; + } + + .candidate-header { + border: 1px solid #ddd; + border-radius: 4px; + padding: 1rem; + margin-bottom: 1rem; + background-color: #f8f8f8; + } + + .candidate-header h3 { + margin: 0 0 0.5rem 0; + } + + .candidate-meta { + color: #666; + font-size: 0.9em; + display: flex; + flex-wrap: wrap; + gap: 1rem; + } + + .candidate-meta-item::after { + content: "•"; + margin-left: 1rem; + color: #ccc; + } + + .candidate-meta-item:last-child::after { + content: none; + } + </style> +{% endblock stylesheets %} + +{% block content %} + <h1>Release candidates</h1> + <p class="intro">Here are all the release candidates to which you have access.</p> + + {% if releases %} + {% for release in releases %} + <div class="candidate-header"> + <h3>{{ release.pmc.project_name }}</h3> + <div class="candidate-meta"> + <span class="candidate-meta-item">Version: {{ release.version }}</span> + <span class="candidate-meta-item">Stage: {{ release.stage.value }}</span> + <span class="candidate-meta-item">Phase: {{ release.phase.value }}</span> + <span class="candidate-meta-item">Created: {{ release.created.strftime("%Y-%m-%d %H:%M UTC") }}</span> + </div> + </div> + + <table class="candidate-table"> + {% for package in release.packages %} + {% if not loop.first %} + <tr class="package-separator"> + <td colspan="2"></td> + </tr> + {% endif %} + <tr> + <th>Original Filename</th> + <td>{{ package.filename }}</td> + </tr> + <tr> + <th>File Hash (SHA3)</th> + <td>{{ package.id_sha3 }}</td> + </tr> + <tr> + <th>Signature Hash (SHA3)</th> + <td>{{ package.signature_sha3 }}</td> + </tr> + <tr> + <th>SHA-512</th> + <td>{{ package.sha512 }}</td> + </tr> + <tr> + <th>Uploaded</th> + <td>{{ package.uploaded.strftime("%Y-%m-%d %H:%M UTC") }}</td> + </tr> + <tr> + <th>Actions</th> + <td class="prose"> + <a class="verify-link" + href="{{ url_for('root_candidate_signatures_verify', release_key=release.storage_key) }}"> + Verify Signatures + </a> + </td> + </tr> + {% endfor %} + </table> + {% endfor %} + {% else %} + <p class="no-releases">You haven't created any releases yet.</p> + {% endif %} + + <p> + <a href="{{ url_for('root_candidate_create') }}">Create a release candidate</a> + </p> +{% endblock content %} diff --git a/atr/templates/release-signature-verify.html b/atr/templates/candidate-signature-verify.html similarity index 67% rename from atr/templates/release-signature-verify.html rename to atr/templates/candidate-signature-verify.html index 0d4c697..f9be33f 100644 --- a/atr/templates/release-signature-verify.html +++ b/atr/templates/candidate-signature-verify.html @@ -11,7 +11,7 @@ {% block stylesheets %} {{ super() }} <style> - .release-info { + .candidate-info { margin-bottom: 2rem; } @@ -37,6 +37,10 @@ background: #f5f5f5; } + .verification-status .status { + font-weight: bold; + } + .navigation { margin-top: 2rem; } @@ -51,7 +55,7 @@ } .status.success { - color: #28a745; + color: #219f3f; } .status.failure { @@ -73,9 +77,20 @@ border: 1px solid #dee2e6; } - .debug-info h3 { - margin-top: 0; + .debug-info summary { color: #666; + font-weight: bold; + cursor: pointer; + padding-bottom: 0.5rem; + } + + .debug-info summary:hover { + color: #333; + } + + .debug-info[open] summary { + border-bottom: 1px solid #dee2e6; + margin-bottom: 1rem; } .debug-info dl { @@ -95,9 +110,38 @@ word-break: break-all; } + .candidate-header { + border: 1px solid #ddd; + border-radius: 4px; + padding: 1rem; + margin-bottom: 1rem; + background-color: #f8f8f8; + } + + .candidate-header h3 { + margin: 0 0 0.5rem 0; + } + + .candidate-meta { + color: #666; + font-size: 0.9em; + display: flex; + flex-wrap: wrap; + gap: 1rem; + } + + .candidate-meta-item::after { + content: "•"; + margin-left: 1rem; + color: #ccc; + } + + .candidate-meta-item:last-child::after { + content: none; + } + pre.stderr { background: #f8f9fa; - padding: 0.5rem; border-radius: 2px; overflow-x: auto; margin: 0.5rem 0; @@ -109,13 +153,14 @@ {% block content %} <h1>Verify release signatures</h1> - <div class="release-info"> - <h2>{{ release.pmc.project_name }}</h2> - <p> - Stage: {{ release.stage.value }} - • - Phase: {{ release.phase.value }} - </p> + <div class="candidate-header"> + <h3>{{ release.pmc.project_name }}</h3> + <div class="candidate-meta"> + <span class="candidate-meta-item">Version: {{ release.version }}</span> + <span class="candidate-meta-item">Stage: {{ release.stage.value }}</span> + <span class="candidate-meta-item">Phase: {{ release.phase.value }}</span> + <span class="candidate-meta-item">Created: {{ release.created.strftime("%Y-%m-%d %H:%M UTC") }}</span> + </div> </div> <div class="package-list"> @@ -140,8 +185,8 @@ {% endif %} {% if result.debug_info %} - <div class="debug-info"> - <h3>Debug Information</h3> + <details class="debug-info"> + <summary>Debug Information</summary> <dl> {% for key, value in result.debug_info.items() %} <dt>{{ key }}</dt> @@ -154,21 +199,10 @@ </dd> {% endfor %} </dl> - </div> + </details> {% endif %} </div> </div> {% endfor %} </div> - - <h2>Navigation</h2> - - <div class="navigation"> - <p> - <a href="{{ url_for('root_user_uploads') }}">Back to Your Uploads</a> - </p> - <p> - <a href="{{ url_for('root_pages') }}">Return to Main Page</a> - </p> - </div> {% endblock content %} diff --git a/atr/templates/includes/sidebar.html b/atr/templates/includes/sidebar.html index 46c8ce9..65ad794 100644 --- a/atr/templates/includes/sidebar.html +++ b/atr/templates/includes/sidebar.html @@ -39,28 +39,32 @@ </ul> {% if current_user %} - <h3>Release management</h3> + <h3>Candidate management</h3> <ul> <li> - <a href="{{ url_for('root_release_create') }}" - {% if request.endpoint == 'root_release_create' %}class="active"{% endif %}>Create release candidate</a> + <a href="{{ url_for('root_candidate_create') }}" + {% if request.endpoint == 'root_candidate_create' %}class="active"{% endif %}>Create candidate</a> </li> <!-- TODO: Don't show this if the user doesn't have any release candidates? --> <li> - <a href="{{ url_for('root_release_attach') }}" - {% if request.endpoint == 'root_release_attach' %}class="active"{% endif %}>Attach package artifacts</a> + <a href="{{ url_for('root_candidate_attach') }}" + {% if request.endpoint == 'root_candidate_attach' %}class="active"{% endif %}>Attach artifacts</a> </li> <li> - <a href="{{ url_for('root_user_uploads') }}" - {% if request.endpoint == 'root_user_uploads' %}class="active"{% endif %}>Your release candidates</a> + <a href="{{ url_for('root_candidate_review') }}" + {% if request.endpoint == 'root_candidate_review' %}class="active"{% endif %}>Review candidates</a> </li> </ul> <h3>User management</h3> <ul> <li> - <a href="{{ url_for('root_user_keys_add') }}" - {% if request.endpoint == 'root_user_keys_add' %}class="active"{% endif %}>Add signing key</a> + <a href="{{ url_for('root_keys_review') }}" + {% if request.endpoint == 'root_keys_review' %}class="active"{% endif %}>Your signing keys</a> + </li> + <li> + <a href="{{ url_for('root_keys_add') }}" + {% if request.endpoint == 'root_keys_add' %}class="active"{% endif %}>Add signing key</a> </li> <li> <a href="{{ url_for('root_user_keys_delete') }}" diff --git a/atr/templates/index.html b/atr/templates/index.html index 66bcd9e..06bf967 100644 --- a/atr/templates/index.html +++ b/atr/templates/index.html @@ -8,7 +8,9 @@ <h1>Apache Trusted Release</h1> <p> - ATR is a release management platform for <a href="https://www.apache.org">Apache Software Foundation</a> projects. It provides a standardized workflow for PMC members to submit, verify, and track release candidates. + ATR is a release management platform for <a href="https://www.apache.org">Apache Software + Foundation</a> projects. It provides a standardized workflow for PMC members to submit, + verify, and track release candidates. </p> <h2>Key Features</h2> @@ -21,20 +23,17 @@ <h2>Getting Started</h2> <p> - To submit a release candidate, you must be a PMC member of the target project. First, <a href="{{ url_for('root_user_keys_add') }}">add your signing key</a>, then <a href="{{ url_for('root_release_create') }}">create a release candidate</a>. + To submit a release candidate, you must be a PMC member of the target project. First, <a href="{{ url_for('root_keys_add') }}">add your signing key</a>, then <a href="{{ url_for('root_candidate_create') }}">create a release candidate</a>. </p> <h2>Documentation</h2> <ul> - <li> - <a href="{{ url_for('root_pages') }}">Available endpoints and access requirements</a> - </li> <li> <a href="{{ url_for('root_pmc_directory') }}">PMC directory and release manager assignments</a> </li> {% if current_user %} <li> - <a href="{{ url_for('root_user_uploads') }}">Track your uploaded release candidates</a> + <a href="{{ url_for('root_candidate_review') }}">Track your release candidates</a> </li> {% endif %} </ul> diff --git a/atr/templates/user-keys-add.html b/atr/templates/keys-add.html similarity index 53% rename from atr/templates/user-keys-add.html rename to atr/templates/keys-add.html index d5e7c13..274c840 100644 --- a/atr/templates/user-keys-add.html +++ b/atr/templates/keys-add.html @@ -5,7 +5,7 @@ {% endblock title %} {% block description %} - Add a GPG public key to your account. + Add a public signing key to your account. {% endblock description %} {% block stylesheets %} @@ -15,7 +15,6 @@ margin-bottom: 1rem; } - /* TODO: Consider moving this to atr.css */ .form-group label { display: inline-block; margin-bottom: 1rem; @@ -57,66 +56,6 @@ margin-top: 2rem; } - .success-message { - color: #28a745; - margin: 1rem 0; - padding: 1rem; - background: #d4edda; - border-radius: 4px; - } - - .existing-keys { - margin-bottom: 2rem; - padding: 1rem 2rem 2rem 2rem; - background: #f8f9fa; - border-radius: 4px; - } - - .keys-grid { - display: grid; - /* This just messes up resizing */ - /* grid-template-columns: repeat(auto-fill, minmax(800px, 1fr)); */ - gap: 1.5rem; - } - - .key-card { - background: white; - border: 1px solid #d1d2d3; - border-radius: 4px; - overflow: hidden; - padding: 1rem; - } - - .key-card table { - margin: 0; - } - - .key-card td { - word-break: break-all; - } - - .key-card h3 { - margin-top: 0; - margin-bottom: 1rem; - } - - .delete-key-form { - margin-top: 1rem; - } - - .delete-button { - background: #dc3545; - color: white; - border: none; - padding: 0.5rem 1rem; - border-radius: 4px; - cursor: pointer; - } - - .delete-button:hover { - background: #c82333; - } - .pmc-checkboxes { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); @@ -146,7 +85,7 @@ {% block content %} <h1>Add signing key</h1> - <p class="intro">Add your GPG public key to use for signing release artifacts.</p> + <p class="intro">Add your public key to use for signing release artifacts.</p> <div class="user-info"> <p> @@ -194,66 +133,13 @@ </div> {% endif %} - {% if success %} - <div class="success-message"> - <h2>Success</h2> - <p>{{ success }}</p> - </div> - {% endif %} - - {% if user_keys %} - <h2>Your Existing Keys</h2> - <div class="existing-keys"> - <div class="keys-grid"> - {% for key in user_keys %} - <div class="key-card"> - <table> - <tbody> - <tr> - <th>Fingerprint</th> - <td>{{ key.fingerprint }}</td> - </tr> - <tr> - <th>Key Type</th> - <td>{{ algorithms[key.algorithm] }} ({{ key.length }} bits)</td> - </tr> - <tr> - <th>Created</th> - <td>{{ key.created.strftime("%Y-%m-%d %H:%M:%S") }}</td> - </tr> - <tr> - <th>Expires</th> - <td>{{ key.expires.strftime("%Y-%m-%d %H:%M:%S") if key.expires else 'Never' }}</td> - </tr> - <tr> - <th>User ID</th> - <td>{{ key.declared_uid or 'Not specified' }}</td> - </tr> - <tr> - <th>Associated projects</th> - <td> - {% if key.pmcs %} - {{ key.pmcs|map(attribute='project_name') |join(', ') }} - {% else %} - No projects associated - {% endif %} - </td> - </tr> - </tbody> - </table> - </div> - {% endfor %} - </div> - </div> - {% endif %} - <form method="post" class="striking"> <div class="form-group"> <label for="public_key">Public Key:</label> <textarea id="public_key" name="public_key" required - placeholder="Paste your GPG public key here (in ASCII-armored format)" + placeholder="Paste your public key here (in ASCII-armored format)" aria-describedby="key-help"></textarea> <small id="key-help"> Your public key should be in ASCII-armored format, starting with "-----BEGIN PGP PUBLIC KEY BLOCK-----" @@ -269,8 +155,7 @@ <input type="checkbox" id="pmc_{{ pmc }}" name="selected_pmcs" - value="{{ pmc }}" - required /> + value="{{ pmc }}" /> <label for="pmc_{{ pmc }}">{{ pmc }}</label> </div> {% endfor %} @@ -285,15 +170,4 @@ <button type="submit">Add Key</button> </form> - - <h2>Navigation</h2> - - <div class="navigation"> - <p> - <a href="{{ url_for('root_user_uploads') }}">Back to Your Uploads</a> - </p> - <p> - <a href="{{ url_for('root_pages') }}">Return to Main Page</a> - </p> - </div> {% endblock content %} diff --git a/atr/templates/keys-review.html b/atr/templates/keys-review.html new file mode 100644 index 0000000..6455709 --- /dev/null +++ b/atr/templates/keys-review.html @@ -0,0 +1,141 @@ +{% extends "layouts/base.html" %} + +{% block title %} + Your signing keys ~ ATR +{% endblock title %} + +{% block description %} + Review your signing keys. +{% endblock description %} + +{% block stylesheets %} + {{ super() }} + <style> + .existing-keys { + margin-bottom: 2rem; + padding: 1rem 2rem 2rem 2rem; + background: #f8f9fa; + border-radius: 4px; + } + + .keys-grid { + display: grid; + gap: 1.5rem; + } + + .key-card { + background: white; + border: 1px solid #d1d2d3; + border-radius: 4px; + overflow: hidden; + padding: 1rem; + } + + .key-card table { + margin: 0; + } + + .key-card td { + word-break: break-all; + } + + .key-card h3 { + margin-top: 0; + margin-bottom: 1rem; + } + + .delete-key-form { + margin-top: 1rem; + } + + .delete-button { + background: #dc3545; + color: white; + border: none; + padding: 0.5rem 1rem; + border-radius: 4px; + cursor: pointer; + } + + .delete-button:hover { + background: #c82333; + } + + .navigation { + margin-top: 2rem; + } + + .success-message { + color: #28a745; + margin: 1rem 0; + padding: 1rem; + background: #d4edda; + border-radius: 4px; + } + </style> +{% endblock stylesheets %} + +{% block content %} + <h1>Your signing keys</h1> + <p class="intro">Review your public keys used for signing release artifacts.</p> + + <div class="user-info"> + <p> + Welcome, <strong>{{ asf_id }}</strong>! You are authenticated as an ASF committer. + </p> + </div> + + {% if success %} + <div class="success-message"> + <h2>Success</h2> + <p>{{ success }}</p> + </div> + {% endif %} + + {% if user_keys %} + <div class="existing-keys"> + <div class="keys-grid"> + {% for key in user_keys %} + <div class="key-card"> + <table> + <tbody> + <tr> + <th>Fingerprint</th> + <td>{{ key.fingerprint }}</td> + </tr> + <tr> + <th>Key Type</th> + <td>{{ algorithms[key.algorithm] }} ({{ key.length }} bits)</td> + </tr> + <tr> + <th>Created</th> + <td>{{ key.created.strftime("%Y-%m-%d %H:%M:%S") }}</td> + </tr> + <tr> + <th>Expires</th> + <td>{{ key.expires.strftime("%Y-%m-%d %H:%M:%S") if key.expires else 'Never' }}</td> + </tr> + <tr> + <th>User ID</th> + <td>{{ key.declared_uid or 'Not specified' }}</td> + </tr> + <tr> + <th>Associated projects</th> + <td> + {% if key.pmcs %} + {{ key.pmcs|map(attribute='project_name') |join(', ') }} + {% else %} + No projects associated + {% endif %} + </td> + </tr> + </tbody> + </table> + </div> + {% endfor %} + </div> + </div> + {% else %} + <p>You haven't added any signing keys yet.</p> + {% endif %} +{% endblock content %} diff --git a/atr/templates/pages.html b/atr/templates/pages.html deleted file mode 100644 index de9db30..0000000 --- a/atr/templates/pages.html +++ /dev/null @@ -1,240 +0,0 @@ -{% extends "layouts/base.html" %} - -{% block title %} - Pages ~ ATR -{% endblock title %} -{% block description %} - List of all pages and endpoints in ATR. -{% endblock description %} - -{% block stylesheets %} - {{ super() }} - <style> - .endpoint-list { - margin: 2rem 0; - } - - .endpoint-group { - margin-bottom: 2rem; - } - - .endpoint { - border: 1px solid #ddd; - padding: 1rem; - margin-bottom: 1rem; - border-radius: 4px; - } - - .endpoint h3 { - margin: 0 0 0.5rem 0; - } - - .endpoint-meta { - color: #666; - font-size: 0.9em; - margin-bottom: 0.5rem; - } - - .endpoint-description { - margin-bottom: 0.5rem; - } - - .access-requirement { - display: inline-block; - padding: 0.25rem 0.5rem; - border-radius: 2px; - font-size: 0.8em; - background: #f5f5f5; - } - - .access-requirement.committer { - background: #e6f3ff; - border: 1px solid #cce5ff; - } - - .access-requirement.admin { - background: #ffeeba; - border: 1px solid #f5d88c; - } - - .access-requirement.public { - background: #e6ffe6; - border: 1px solid #ccebcc; - } - - .access-requirement.warning { - background: #ffe6e6; - border: 1px solid #ffcccc; - color: #cc0000; - font-weight: bold; - } - </style> -{% endblock stylesheets %} - -{% block content %} - <h1>Pages</h1> - <p class="intro">A complete list of all pages and endpoints available in ATR.</p> - - <div class="endpoint-list"> - <div class="endpoint-group"> - <h2>Main Pages</h2> - - <div class="endpoint"> - <h3> - <a href="{{ url_for('root') }}">/</a> - </h3> - <div class="endpoint-description">Main welcome page.</div> - <div class="endpoint-meta"> - Access: <span class="access-requirement public">Public</span> - </div> - </div> - - <div class="endpoint"> - <h3> - <a href="{{ url_for('root_pages') }}">/pages</a> - </h3> - <div class="endpoint-description">List of all pages on the website (this page).</div> - <div class="endpoint-meta"> - Access: <span class="access-requirement public">Public</span> - </div> - </div> - </div> - - <div class="endpoint-group"> - <h2>PMC Management</h2> - - <div class="endpoint"> - <h3> - <a href="{{ url_for('root_pmc_directory') }}">/pmc/directory</a> - </h3> - <div class="endpoint-description">Main PMC directory page with all PMCs and their latest releases.</div> - <div class="endpoint-meta"> - Access: <span class="access-requirement public">Public</span> - </div> - </div> - - <div class="endpoint"> - <h3> - <a href="{{ url_for('root_pmc_list') }}">/pmc/list</a> - </h3> - <div class="endpoint-description">List all PMCs in the database (JSON format).</div> - <div class="endpoint-meta"> - Access: <span class="access-requirement public">Public</span> - </div> - </div> - - <div class="endpoint"> - <h3>/pmc/<project_name></h3> - <div class="endpoint-description">Get details for a specific PMC (JSON format).</div> - <div class="endpoint-meta"> - Access: <span class="access-requirement public">Public</span> - </div> - </div> - </div> - - <div class="endpoint-group"> - <h2>Release Management</h2> - - <div class="endpoint"> - <h3> - <a href="{{ url_for('root_release_create') }}">/release/create</a> - </h3> - <div class="endpoint-description">Add a release candidate to the database.</div> - <div class="endpoint-meta"> - Access: <span class="access-requirement committer">Committer</span> - <br /> - Additional requirement: Must be PMC member of the target project - </div> - </div> - - <div class="endpoint"> - <h3> - <a href="{{ url_for('root_user_uploads') }}">/user/uploads</a> - </h3> - <div class="endpoint-description">Show all release candidates uploaded by the current user.</div> - <div class="endpoint-meta"> - Access: <span class="access-requirement committer">Committer</span> - </div> - </div> - - <div class="endpoint"> - <h3>/release/signatures/verify/<release_key></h3> - <div class="endpoint-description">Verify GPG signatures for all packages in a release candidate.</div> - <div class="endpoint-meta"> - Access: <span class="access-requirement committer">Committer</span> - </div> - </div> - </div> - - <div class="endpoint-group"> - <h2>User Management</h2> - - <div class="endpoint"> - <h3> - <a href="{{ url_for('root_user_keys_add') }}">/user/keys/add</a> - </h3> - <div class="endpoint-description">Add a GPG public key to your account for signing releases.</div> - <div class="endpoint-meta"> - Access: <span class="access-requirement committer">Committer</span> - </div> - </div> - - <div class="endpoint"> - <h3> - <a href="{{ url_for('root_user_keys_delete') }}">/user/keys/delete</a> - </h3> - <div class="endpoint-description">Delete all GPG keys associated with your account.</div> - <div class="endpoint-meta"> - Access: <span class="access-requirement committer">Committer</span> - <br /> - <span class="access-requirement warning">Warning: This will delete all your keys without confirmation!</span> - </div> - </div> - </div> - - <div class="endpoint-group"> - <h2>Administration</h2> - - <div class="endpoint"> - <h3> - <a href="{{ url_for('secret_blueprint.secret_data') }}">/secret/data</a> - </h3> - <div class="endpoint-description">Browse all records in the database.</div> - <div class="endpoint-meta"> - Access: <span class="access-requirement committer">Committer</span> - <span class="access-requirement admin">Admin</span> - <br /> - Additional requirement: Must be in ALLOWED_USERS list - </div> - </div> - - <div class="endpoint"> - <h3> - <a href="{{ url_for('secret_blueprint.secret_pmcs_update') }}">/secret/pmcs/update</a> - </h3> - <div class="endpoint-description">Update PMCs from remote, authoritative committee-info.json.</div> - <div class="endpoint-meta"> - Access: <span class="access-requirement committer">Committer</span> - <span class="access-requirement admin">Admin</span> - <br /> - Additional requirement: Must be in ALLOWED_USERS list - </div> - </div> - </div> - - <div class="endpoint-group"> - <h2>API</h2> - - <div class="endpoint"> - <h3> - <a href="{{ url_for('swagger_ui') }}">/api/docs</a> - </h3> - <div class="endpoint-description">Swagger UI.</div> - <div class="endpoint-meta"> - Access: <span class="access-requirement public">Public</span> - </div> - </div> - </div> - </div> - -{% endblock content %} diff --git a/atr/templates/user-uploads.html b/atr/templates/user-uploads.html deleted file mode 100644 index 48df508..0000000 --- a/atr/templates/user-uploads.html +++ /dev/null @@ -1,103 +0,0 @@ -{% extends "layouts/base.html" %} - -{% block title %} - Release candidates ~ ATR -{% endblock title %} - -{% block description %} - Release candidates to which you have access. -{% endblock description %} - -{% block stylesheets %} - {{ super() }} - <style> - .release-list { - margin: 1rem 0; - } - - div.release { - border: 1px solid #ddd; - padding: 1rem; - margin-bottom: 1rem; - border-radius: 4px; - } - - .release-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 0.5rem; - } - - .release-meta { - color: #666; - font-size: 0.9em; - } - - .package-list { - margin-top: 0.5rem; - } - - .package { - background: #f5f5f5; - padding: 0.5rem; - margin: 0.25rem 0; - border-radius: 2px; - } - - .package div { - margin-bottom: 0.25rem; - } - - .no-releases { - color: #666; - font-style: italic; - } - </style> -{% endblock stylesheets %} - -{% block content %} - <h1>Release candidates</h1> - <p class="intro">Here are all the release candidates to which you have access.</p> - - {% if releases %} - <div class="release-list"> - {% for release in releases %} - <div class="release"> - <div class="release-header"> - <h3>{{ release.pmc.project_name }}</h3> - <span class="release-meta"> - Stage: {{ release.stage.value }} - • - Phase: {{ release.phase.value }} - </span> - </div> - <div class="package-list"> - {% for package in release.packages %} - <div class="package"> - <div> - File: <span class="hex">{{ package.file }}</span> - </div> - <div> - Signature: <span class="hex">{{ package.signature }}</span> - </div> - <div> - Checksum (SHA-512): <span class="hex">{{ package.checksum }}</span> - </div> - <p class="package-actions"> - <a href="{{ url_for('root_release_signatures_verify', release_key=release.storage_key) }}">Verify Signatures</a> - </p> - </div> - {% endfor %} - </div> - </div> - {% endfor %} - </div> - {% else %} - <p class="no-releases">You haven't created any releases yet.</p> - {% endif %} - - <p> - <a href="{{ url_for('root_release_create') }}">Create a release candidate</a> - </p> -{% endblock content %} diff --git a/migrations/versions/512e973a9ce4_initial_schema.py b/migrations/versions/b561e6142755_initial_schema.py similarity index 84% rename from migrations/versions/512e973a9ce4_initial_schema.py rename to migrations/versions/b561e6142755_initial_schema.py index c9f6768..991d9e4 100644 --- a/migrations/versions/512e973a9ce4_initial_schema.py +++ b/migrations/versions/b561e6142755_initial_schema.py @@ -1,15 +1,15 @@ """initial_schema -Revision ID: 512e973a9ce4 +Revision ID: b561e6142755 Revises: -Create Date: 2025-02-18 16:37:04.346002 +Create Date: 2025-02-19 18:52:21.878941 """ from collections.abc import Sequence # revision identifiers, used by Alembic. -revision: str = "512e973a9ce4" +revision: str = "b561e6142755" down_revision: str | None = None branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None --------------------------------------------------------------------- To unsubscribe, e-mail: dev-unsubscr...@tooling.apache.org For additional commands, e-mail: dev-h...@tooling.apache.org