You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tooling.apache.org by sb...@apache.org on 2026/07/15 15:42:56 UTC
(tooling-trusted-releases) branch sbp updated (d98e4188 -> 19e5067b)
This is an automated email from the ASF dual-hosted git repository.
sbp pushed a change to branch sbp
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git
from d98e4188 Update CodeQL actions, and ensure smoother Dependabot updates
add d548108f Bump astral-sh/setup-uv from 8.3.1 to 8.3.2
add a11f55c0 Bump the codeql-action group with 2 updates
add 99e8934f Bump advanced-security/dismiss-alerts from 2.0.2 to 2.0.3
new 19e5067b Use existing KEYS URLs instead of linking to the removed downloads directory
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
Summary of changes:
.github/workflows/analyze.yml | 2 +-
.github/workflows/build.yml | 2 +-
.github/workflows/codeql.yaml | 6 ++--
atr/api/__init__.py | 37 +---------------------
atr/construct.py | 12 +-------
atr/models/api.py | 2 --
atr/paths.py | 13 +++++---
atr/post/keys.py | 10 +-----
tests/unit/test_construct_announce.py | 54 +++++++++++++++++++++++++++++++++
tests/unit/test_keys_post.py | 30 ++++++++++++++++++
tests/unit/test_paths.py | 27 +++++++++++++++++
tests/unit/test_signature_provenance.py | 51 +++++++++++++++++++++++++++++++
12 files changed, 179 insertions(+), 67 deletions(-)
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org
(tooling-trusted-releases) 01/01: Use existing KEYS URLs instead of linking to the removed downloads directory
Posted by sb...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
sbp pushed a commit to branch sbp
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git
commit 19e5067b5bf6911cc1d2eef6bc5e652a3dd9759e
Author: Sean B. Palmer <se...@miscoranda.com>
AuthorDate: Wed Jul 15 16:42:08 2026 +0100
Use existing KEYS URLs instead of linking to the removed downloads directory
---
atr/api/__init__.py | 37 +---------------------
atr/construct.py | 12 +-------
atr/models/api.py | 2 --
atr/paths.py | 13 +++++---
atr/post/keys.py | 10 +-----
tests/unit/test_construct_announce.py | 54 +++++++++++++++++++++++++++++++++
tests/unit/test_keys_post.py | 30 ++++++++++++++++++
tests/unit/test_paths.py | 27 +++++++++++++++++
tests/unit/test_signature_provenance.py | 51 +++++++++++++++++++++++++++++++
9 files changed, 174 insertions(+), 62 deletions(-)
diff --git a/atr/api/__init__.py b/atr/api/__init__.py
index a1c00e09..95da4c2f 100644
--- a/atr/api/__init__.py
+++ b/atr/api/__init__.py
@@ -36,7 +36,6 @@ import atr.constants as constants
import atr.construct as construct
import atr.db as db
import atr.db.interaction as interaction
-import atr.hashes as hashes
import atr.ldap as ldap
import atr.log as log
import atr.models as models
@@ -1580,9 +1579,6 @@ async def signature_provenance(
raise exceptions.BadRequest("version_key requires project_key")
signing_keys: list[models.api.SignatureProvenanceKey] = []
- conf = config.get()
- host = conf.APP_HOST
-
signature_asc_data = data.signature_asc_text
sig, _ = openpgp.DetachedSignature.from_armor(signature_asc_data)
signature_info = sig.signature_info()
@@ -1601,25 +1597,7 @@ async def signature_provenance(
matched_committees = await _match_committees(key.committees, data, _jwt_asf_uid())
for committee in matched_committees:
- keys_file_path = paths.committee_downloads_dir(committee) / "KEYS"
- try:
- async with aiofiles.open(keys_file_path, "rb") as f:
- keys_file_data = await f.read()
- except FileNotFoundError:
- continue
- except OSError:
- log.exception(f"Failed to read KEYS file for committee {committee.key}")
- continue
- keys_file_sha3_256 = hashes.compute_sha3_256(keys_file_data)
- signing_keys.append(
- models.api.SignatureProvenanceKey(
- committee=committee.key,
- keys_file_url=util.public_download_url(
- committee, None, util.DownloadFile.METADATA, filename="KEYS", host=host
- ),
- keys_file_sha3_256=keys_file_sha3_256,
- )
- )
+ signing_keys.append(models.api.SignatureProvenanceKey(committee=committee.key))
if not signing_keys:
raise exceptions.NotFound("No signing keys found")
@@ -2134,19 +2112,6 @@ async def test_archive_project(
return {"ok": True, "project_key": str(data.project_key)}, 200
-def _committee_keys_path(committee: sql.Committee) -> safe.StatePath:
- downloads_dir = paths.get_downloads_dir()
- if committee.is_podling:
- return downloads_dir / "incubator" / committee.key / "KEYS"
- return downloads_dir / committee.key / "KEYS"
-
-
-def _committee_keys_url(host: str, committee: sql.Committee) -> str:
- if committee.is_podling:
- return f"https://{host}/downloads/incubator/{committee.key}/KEYS"
- return f"https://{host}/downloads/{committee.key}/KEYS"
-
-
def _jwt_asf_uid() -> str:
claims = getattr(quart.g, "jwt_claims", {})
asf_uid = claims.get("sub")
diff --git a/atr/construct.py b/atr/construct.py
index 20417c14..fcd3078d 100644
--- a/atr/construct.py
+++ b/atr/construct.py
@@ -22,8 +22,6 @@ import re
from collections.abc import Mapping
from typing import Final, Literal, TypedDict, overload
-import aiofiles.os
-
import atr.config as config
import atr.db as db
import atr.mail as mail
@@ -389,13 +387,6 @@ async def start_vote_subject_and_body(subject: str, body: str, options: StartVot
vote_end = datetime.datetime.now(datetime.UTC) + datetime.timedelta(hours=options.vote_duration)
vote_end_str = f"{vote_end.day} {vote_end.strftime('%b %H:%M')} UTC"
- # NOTE: The /downloads/ directory is served by the proxy front end, not by ATR
- # Therefore there is no route handler, so we have to construct the URL manually
- keys_file = None
- keys_file_path = paths.committee_downloads_dir(committee) / "KEYS"
- if await aiofiles.os.path.isfile(keys_file_path):
- keys_file = util.public_download_url(committee, None, util.DownloadFile.METADATA, filename="KEYS", host=host)
-
checklist_content = ""
async with db.session() as data:
release_policy = await db.get_project_release_policy(data, options.project_key)
@@ -425,7 +416,7 @@ async def start_vote_subject_and_body(subject: str, body: str, options: StartVot
"COMMITTEE": committee.display_name,
"DURATION": str(options.vote_duration or "an unlimited number of"),
"HOMEPAGE": project.homepage or "",
- "KEYS_FILE": keys_file or "(Sorry, the KEYS file is missing!)",
+ "KEYS_FILE": paths.committee_keys_url(committee),
"PROJECT_NAME": project_display_name,
"PROJECT_KEY": project.key,
"RELEASE_CHECKLIST": checklist_content,
@@ -437,7 +428,6 @@ async def start_vote_subject_and_body(subject: str, body: str, options: StartVot
"YOUR_ASF_ID": options.asfuid,
"YOUR_FULL_NAME": options.fullname,
}
- body_values["KEYS_FILE"] = util.released_dist_url(body_values["KEYS_FILE"])
subject = _substitute(subject, subject_values, "vote_subject")
body = _substitute(body, body_values, "vote")
return subject, body
diff --git a/atr/models/api.py b/atr/models/api.py
index 42de59fc..fb079e5a 100644
--- a/atr/models/api.py
+++ b/atr/models/api.py
@@ -793,8 +793,6 @@ class SignatureProvenanceArgs(schema.Strict):
class SignatureProvenanceKey(schema.Strict):
committee: str = schema.example("example")
- keys_file_url: str = schema.example("https://example.apache.org/example/KEYS")
- keys_file_sha3_256: str = schema.example("0123456789abcdef0123456789abcdef01234567")
class SignatureProvenanceResults(schema.Strict):
diff --git a/atr/paths.py b/atr/paths.py
index e36478d2..b99592d4 100644
--- a/atr/paths.py
+++ b/atr/paths.py
@@ -23,6 +23,12 @@ import atr.models.safe as safe
import atr.models.sql as sql
+def archive_download_url(relpath: safe.RelPath) -> str:
+ # archive.apache.org keeps every release ever published, including those pruned from the
+ # live mirror, so an archived release's files are served from here regardless of host.
+ return f"{constants.ARCHIVE_APACHE_URL}/{relpath}"
+
+
def base_path_for_revision(
project_key: safe.ProjectKey, version_key: safe.VersionKey, revision: safe.RevisionNumber
) -> safe.StatePath:
@@ -65,10 +71,9 @@ def committee_downloads_url(host: str, committee: sql.Committee) -> str:
return f"https://{host}/downloads/{committee.key}"
-def archive_download_url(relpath: safe.RelPath) -> str:
- # archive.apache.org keeps every release ever published, including those pruned from the
- # live mirror, so an archived release's files are served from here regardless of host.
- return f"{constants.ARCHIVE_APACHE_URL}/{relpath}"
+def committee_keys_url(committee: sql.Committee) -> str:
+ prefix = "incubator/" if committee.is_podling else ""
+ return downloads_url(safe.RelPath(f"{prefix}{committee.key}/KEYS"))
def downloads_url(relpath: safe.RelPath) -> str:
diff --git a/atr/post/keys.py b/atr/post/keys.py
index c038450b..f71e0ae0 100644
--- a/atr/post/keys.py
+++ b/atr/post/keys.py
@@ -268,12 +268,6 @@ async def _add_key_text_resolve(session: web.Committer, add_form: shared.keys.Ad
return key_text
-def _construct_keys_url(committee_key: str, *, is_podling: bool) -> str:
- # KEYS comes from downloads.apache.org, not a mirror.
- prefix = "incubator/" if is_podling else ""
- return paths.downloads_url(safe.RelPath(f"{prefix}{committee_key}/KEYS"))
-
-
async def _delete_openpgp_key(
session: web.Committer, delete_form: shared.keys.DeleteOpenPGPKeyForm
) -> web.WerkzeugResponse:
@@ -454,9 +448,7 @@ async def _upload_remote_keys(upload_remote_form: shared.keys.UploadRemoteForm)
if not committee:
await quart.flash(f"Committee '{selected_committee}' not found", "error")
return await shared.keys.render_upload_page(error=True)
- is_podling = committee.is_podling
-
- keys_url = _construct_keys_url(selected_committee, is_podling=is_podling)
+ keys_url = paths.committee_keys_url(committee)
keys_text = await _fetch_keys_from_url(keys_url)
if util.contains_private_key_text(keys_text):
diff --git a/tests/unit/test_construct_announce.py b/tests/unit/test_construct_announce.py
index e7e9237e..5e119340 100644
--- a/tests/unit/test_construct_announce.py
+++ b/tests/unit/test_construct_announce.py
@@ -170,6 +170,60 @@ async def test_announce_release_subject_and_body_uses_top_level_downloads_url(mo
assert body == "https://downloads.apache.org/downloads/myproject/"
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ ("is_podling", "expected_url"),
+ [
+ (False, "https://downloads.apache.org/myproject/KEYS"),
+ (True, "https://downloads.apache.org/incubator/myproject/KEYS"),
+ ],
+)
+async def test_start_vote_subject_and_body_uses_canonical_keys_url(
+ monkeypatch, is_podling: bool, expected_url: str
+) -> None:
+ committee = SimpleNamespace(key="myproject", is_podling=is_podling, display_name="Apache MyProject")
+ project = SimpleNamespace(
+ short_display_name="Apache MyProject",
+ key="myproject",
+ bug_database=None,
+ homepage=None,
+ repositories=[],
+ )
+ release = SimpleNamespace(key="myproject-1.0.0", committee=committee, project=project)
+ revision = SimpleNamespace(number="1", tag=None)
+
+ async def no_release_policy(_data, _project_key):
+ return None
+
+ monkeypatch.setattr(
+ construct.config,
+ "get",
+ lambda: SimpleNamespace(
+ APP_HOST="atr.example.invalid",
+ SVN_PUBLISH_URL="https://svn.example.invalid/repos/dist/atr",
+ SVN_DIST_PUBLIC_URL="https://dist.example.invalid/repos/dist/atr",
+ ),
+ )
+ monkeypatch.setattr(construct.db, "session", _mock_session_factory(MockDBSession(release, revision)))
+ monkeypatch.setattr(construct.db, "get_project_release_policy", no_release_policy)
+ monkeypatch.setattr(construct.util, "as_url", lambda *_args, **_kwargs: "/example")
+
+ _subject, body = await construct.start_vote_subject_and_body(
+ "",
+ "{{KEYS_FILE}}",
+ construct.StartVoteOptions(
+ asfuid="sbp",
+ fullname="Some Body",
+ project_key=safe.ProjectKey("myproject"),
+ version_key=safe.VersionKey("1.0.0"),
+ revision_number=safe.RevisionNumber("1"),
+ vote_duration=72,
+ ),
+ )
+
+ assert body == expected_url
+
+
def test_substitute_does_not_rescan_replacement_values() -> None:
result = construct._substitute(
"{{PROJECT_NAME}} {{VERSION}} {{UNKNOWN}}",
diff --git a/tests/unit/test_keys_post.py b/tests/unit/test_keys_post.py
index dee48cd7..84fe55e8 100644
--- a/tests/unit/test_keys_post.py
+++ b/tests/unit/test_keys_post.py
@@ -15,7 +15,10 @@
# specific language governing permissions and limitations
# under the License.
+import contextlib
import datetime
+from types import SimpleNamespace
+from unittest import mock
import pytest
@@ -40,6 +43,33 @@ def test_openpgp_key_uid_warning_flags_other_asf_uid(monkeypatch: pytest.MonkeyP
assert "/keys/details/fp" in warning_html
+@pytest.mark.asyncio
+async def test_upload_remote_keys_uses_canonical_committee_url(monkeypatch: pytest.MonkeyPatch) -> None:
+ committee = SimpleNamespace(key="example", is_podling=True)
+ query = SimpleNamespace(get=mock.AsyncMock(return_value=committee))
+ data = SimpleNamespace(committee=mock.Mock(return_value=query))
+
+ @contextlib.asynccontextmanager
+ async def db_session():
+ yield data
+
+ canonical_url = "https://downloads.apache.org/incubator/example/KEYS"
+ committee_keys_url = mock.Mock(return_value=canonical_url)
+ fetch = mock.AsyncMock(return_value="public keys")
+ process = mock.AsyncMock(return_value="rendered")
+ monkeypatch.setattr(keys.db, "session", db_session)
+ monkeypatch.setattr(keys.paths, "committee_keys_url", committee_keys_url)
+ monkeypatch.setattr(keys, "_fetch_keys_from_url", fetch)
+ monkeypatch.setattr(keys, "_process_keys", process)
+ monkeypatch.setattr(keys.util, "contains_private_key_text", lambda _text: False)
+
+ result = await keys._upload_remote_keys(SimpleNamespace(committee="example"))
+
+ assert result == "rendered"
+ committee_keys_url.assert_called_once_with(committee)
+ fetch.assert_awaited_once_with(canonical_url)
+
+
def _public_key(apache_uid: str | None) -> sql.PublicSigningKey:
return sql.PublicSigningKey(
fingerprint="fp",
diff --git a/tests/unit/test_paths.py b/tests/unit/test_paths.py
index 28730425..950d7a80 100644
--- a/tests/unit/test_paths.py
+++ b/tests/unit/test_paths.py
@@ -54,6 +54,33 @@ def test_committee_downloads_url_for_top_level_committee() -> None:
)
+def test_committee_keys_url_for_podling() -> None:
+ committee = types.SimpleNamespace(key="myproject", is_podling=True)
+
+ assert paths.committee_keys_url(committee) == "https://downloads.apache.org/incubator/myproject/KEYS"
+
+
+def test_committee_keys_url_for_top_level_committee() -> None:
+ committee = types.SimpleNamespace(key="myproject", is_podling=False)
+
+ assert paths.committee_keys_url(committee) == "https://downloads.apache.org/myproject/KEYS"
+
+
+def test_committee_keys_url_ignores_application_and_svn_hosts(monkeypatch) -> None:
+ committee = types.SimpleNamespace(key="myproject", is_podling=False)
+ monkeypatch.setattr(
+ paths.config,
+ "get",
+ lambda: types.SimpleNamespace(
+ APP_HOST="atr.example.invalid",
+ SVN_PUBLISH_URL="https://svn.example.invalid/repos/dist/atr",
+ SVN_DIST_PUBLIC_URL="https://dist.example.invalid/repos/dist/atr",
+ ),
+ )
+
+ assert paths.committee_keys_url(committee) == "https://downloads.apache.org/myproject/KEYS"
+
+
def test_get_quarantined_dir_uses_state_dir(monkeypatch, tmp_path: pathlib.Path):
mock_config = types.SimpleNamespace(STATE_DIR=str(tmp_path))
monkeypatch.setattr("atr.config.get", lambda: mock_config)
diff --git a/tests/unit/test_signature_provenance.py b/tests/unit/test_signature_provenance.py
index 789483b0..c7fae572 100644
--- a/tests/unit/test_signature_provenance.py
+++ b/tests/unit/test_signature_provenance.py
@@ -17,6 +17,7 @@
import contextlib
import hashlib
+import inspect
import pathlib
import unittest.mock as mock
from collections.abc import AsyncGenerator
@@ -330,6 +331,21 @@ async def test_scoped_returns_empty_when_version_not_found() -> None:
assert result == []
+@pytest.mark.asyncio
+async def test_signature_provenance_requires_matched_committee(monkeypatch: pytest.MonkeyPatch) -> None:
+ with pytest.raises(atr.api.exceptions.NotFound, match="No signing keys found"):
+ await _call_signature_provenance(monkeypatch, matched=False)
+
+
+@pytest.mark.asyncio
+async def test_signature_provenance_returns_database_committees(monkeypatch: pytest.MonkeyPatch) -> None:
+ result, status = await _call_signature_provenance(monkeypatch, matched=True)
+
+ assert status == 200
+ assert result["committees_with_artifact"] == [{"committee": "example"}]
+ assert result["key_asc_text"] == _EMBEDDED_SUBKEY_PUBLIC_KEY_ASC
+
+
@pytest.mark.asyncio
async def test_unscoped_finds_matching_committee(tmp_path: pathlib.Path) -> None:
committee = SimpleNamespace(key="example-pmc", is_podling=False)
@@ -387,6 +403,41 @@ async def test_unscoped_returns_empty_when_no_match(tmp_path: pathlib.Path) -> N
assert result == []
+async def _call_signature_provenance(
+ monkeypatch: pytest.MonkeyPatch,
+ matched: bool,
+) -> tuple[dict, int]:
+ parsed_key, _ = atr.api.openpgp.PublicKey.from_armor(_EMBEDDED_SUBKEY_PUBLIC_KEY_ASC)
+ committee = SimpleNamespace(key="example", is_podling=False)
+ stored = SimpleNamespace(
+ fingerprint=parsed_key.fingerprint.lower(),
+ ascii_armored_key=_EMBEDDED_SUBKEY_PUBLIC_KEY_ASC,
+ committees=[committee],
+ )
+ signature = SimpleNamespace(
+ signature_info=lambda: SimpleNamespace(
+ issuer_fingerprints={parsed_key.fingerprint.lower()},
+ issuer_key_ids=set(),
+ )
+ )
+ matched_committees = [committee] if matched else []
+
+ monkeypatch.setattr(atr.api.db, "session", _mock_session_factory(MockKeyDBSession([stored])))
+ monkeypatch.setattr(
+ atr.api.openpgp,
+ "DetachedSignature",
+ SimpleNamespace(from_armor=lambda _text: (signature, "")),
+ )
+ monkeypatch.setattr(atr.api, "_match_committees", mock.AsyncMock(return_value=matched_committees))
+ monkeypatch.setattr(atr.api, "_jwt_asf_uid", lambda: "tester")
+
+ signature_provenance = inspect.unwrap(atr.api.signature_provenance)
+ return await signature_provenance(
+ "signature/provenance",
+ _make_args("example.tar.gz.asc", "abc123"),
+ )
+
+
def _make_args(
signature_file_name: str,
signature_sha3_256: str,
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org