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/14 15:10:41 UTC

(tooling-trusted-releases) 01/01: Allow SHA-512 files to be generated without signatures

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 2f2d1e786a4ddd3aabb6d7970a4fd1b5e54d35b4
Author: Sean B. Palmer <se...@miscoranda.com>
AuthorDate: Tue Jul 14 16:10:14 2026 +0100

    Allow SHA-512 files to be generated without signatures
---
 atr/models/attestable.py                  |  1 +
 atr/storage/writers/release.py            | 58 +++++++------------
 atr/storage/writers/revision.py           |  2 -
 atr/templates/draft-tools.html            |  6 +-
 tests/unit/test_release_writer_hashgen.py | 95 ++++++++++++++++++-------------
 5 files changed, 82 insertions(+), 80 deletions(-)

diff --git a/atr/models/attestable.py b/atr/models/attestable.py
index b9483dbb..8ea29432 100644
--- a/atr/models/attestable.py
+++ b/atr/models/attestable.py
@@ -54,6 +54,7 @@ class HashEntryV2(schema.Strict):
 
 
 class GeneratorV2(enum.Enum):
+    SHA512_FROM_CONTENT = "SHA512_from_content"
     SHA512_FROM_SIGNATURE = "SHA512_from_signature"
 
 
diff --git a/atr/storage/writers/release.py b/atr/storage/writers/release.py
index 84329abc..249721a1 100644
--- a/atr/storage/writers/release.py
+++ b/atr/storage/writers/release.py
@@ -246,9 +246,9 @@ async def _signature_provenance_metadata_for(
     version_key: safe.VersionKey,
     parent_revision: sql.Revision | None,
     signature_rel_path: pathlib.Path,
-) -> dict[str, str]:
+) -> dict[str, str] | None:
     if parent_revision is None:
-        raise datatypes.FailedError("SHA512 generation requires a parent revision with a verified signature.")
+        return None
     release_key = sql.release_key(str(project_key), str(version_key))
     parent_revision_number = parent_revision.safe_number
     parent_number = str(parent_revision_number)
@@ -263,13 +263,10 @@ async def _signature_provenance_metadata_for(
     )
     if not check_results:
         log.info(
-            "SHA512 generation is waiting for signature verification"
+            "SHA512 generation proceeding without signature provenance"
             f" release={release_key} revision={parent_number} path={signature_rel_str}"
         )
-        raise datatypes.FailedError(
-            "Signature verification has not completed yet for"
-            f" {signature_rel_path.name}. Please wait for checks to finish and try again."
-        )
+        return None
     latest = check_results[0]
     latest_message = latest.message.strip() if isinstance(getattr(latest, "message", None), str) else None
     log.info(
@@ -279,14 +276,7 @@ async def _signature_provenance_metadata_for(
         f" status={latest.status.value} message={latest_message!r}"
     )
     if latest.status != sql.CheckResultStatus.NOTE:
-        if latest_message:
-            raise datatypes.FailedError(
-                f"Signature verification for {signature_rel_path.name} failed: {latest_message}"
-            )
-        raise datatypes.FailedError(
-            "Signature verification for"
-            f" {signature_rel_path.name} is {latest.status.value}; cannot generate the SHA512 yet."
-        )
+        return None
     payload = latest.data if isinstance(latest.data, dict) else {}
     metadata = {"signature_path": signature_rel_str}
     for key in ("fingerprint", "key_id", "timestamp", "username"):
@@ -674,35 +664,31 @@ class CommitteeParticipant(FoundationCommitter):
             if await aiofiles.os.path.exists(hash_path_in_new_revision):
                 raise storage.AccessError("SHA512 file already exists", status=409)
 
-            if not await aiofiles.os.path.exists(signature_path_in_new_revision):
-                raise datatypes.FailedError(
-                    "SHA512 generation requires a detached OpenPGP signature at"
-                    f" {signature_rel_path!s}. Please upload the signature first."
+            signature_metadata = None
+            if await aiofiles.os.path.exists(signature_path_in_new_revision):
+                signature_metadata = await _signature_provenance_metadata_for(
+                    project_key=project_key,
+                    version_key=version_key,
+                    parent_revision=old_rev,
+                    signature_rel_path=signature_rel_path,
                 )
 
-            signature_metadata = await _signature_provenance_metadata_for(
-                project_key=project_key,
-                version_key=version_key,
-                parent_revision=old_rev,
-                signature_rel_path=signature_rel_path,
-            )
-
             hash_value, source_content_hash = await hashes.compute_sha512_and_content_hash(path_in_new_revision.path)
 
             async with aiofiles.open(hash_path_in_new_revision, "w") as f:
                 await f.write(f"{hash_value}  {rel_path.name}\n")
 
             generated_rel = safe.RelPath(str(rel_path.parent / hash_path_rel_name))
-            provenance = attestable.ProvenanceV2(
-                generator=attestable.GeneratorV2.SHA512_FROM_SIGNATURE,
-                metadata={
-                    "initiated_by": self.__asf_uid,
-                    "signature_path": signature_metadata["signature_path"],
-                    "source_content_hashes": {str(rel_path): source_content_hash},
-                    "source_paths": [str(rel_path)],
-                    **{key: value for key, value in signature_metadata.items() if (key != "signature_path")},
-                },
-            )
+            generator = attestable.GeneratorV2.SHA512_FROM_CONTENT
+            metadata: dict[str, Any] = {
+                "initiated_by": self.__asf_uid,
+                "source_content_hashes": {str(rel_path): source_content_hash},
+                "source_paths": [str(rel_path)],
+            }
+            if signature_metadata is not None:
+                generator = attestable.GeneratorV2.SHA512_FROM_SIGNATURE
+                metadata.update(signature_metadata)
+            provenance = attestable.ProvenanceV2(generator=generator, metadata=metadata)
             return {generated_rel: provenance}
 
         await self.__write_as.revision.create_revision_with_quarantine(
diff --git a/atr/storage/writers/revision.py b/atr/storage/writers/revision.py
index ca8f8a7a..6102f5d0 100644
--- a/atr/storage/writers/revision.py
+++ b/atr/storage/writers/revision.py
@@ -334,8 +334,6 @@ async def _verify_provenance_sources_unchanged(
     # This function checks input file inodes to ensure that they match
     # If they don't match, we raise an error
     for generated_rel, provenance_entry in path_provenance.items():
-        if provenance_entry.generator != atr.models.attestable.GeneratorV2.SHA512_FROM_SIGNATURE:
-            continue
         dependency_paths = [path for path in provenance_entry.metadata.get("source_paths", []) if isinstance(path, str)]
         if (signature_path := provenance_entry.metadata.get("signature_path")) is not None:
             if isinstance(signature_path, str) and (signature_path not in dependency_paths):
diff --git a/atr/templates/draft-tools.html b/atr/templates/draft-tools.html
index d2f434e2..0b2155a9 100644
--- a/atr/templates/draft-tools.html
+++ b/atr/templates/draft-tools.html
@@ -24,9 +24,9 @@
   <h2>Tools</h2>
   <h3>Generate hash files</h3>
   <p>
-    Generate an SHA512 hash file for this file. ATR requires a detached OpenPGP
-    signature (<code>.asc</code>) alongside the file, and the signature check must
-    have completed successfully, before the SHA512 can be generated.
+    Generate an SHA512 hash file for this file. If a detached OpenPGP signature
+    (<code>.asc</code>) is present alongside the file and its check has completed
+    successfully, the signature is recorded in the provenance of the SHA512 file.
   </p>
   <div class="d-flex gap-2 mb-4">
     {{ sha512_form|safe }}
diff --git a/tests/unit/test_release_writer_hashgen.py b/tests/unit/test_release_writer_hashgen.py
index 5a6ddfb4..84908aa7 100644
--- a/tests/unit/test_release_writer_hashgen.py
+++ b/tests/unit/test_release_writer_hashgen.py
@@ -25,7 +25,6 @@ import pytest
 import atr.models.attestable as attestable
 import atr.models.safe as safe
 import atr.models.sql as sql
-import atr.storage.datatypes as datatypes
 import atr.storage.writers.release as release
 
 
@@ -129,11 +128,12 @@ async def test_generate_hash_file_creates_hash_file_and_returns_provenance(tmp_p
 
 
 @pytest.mark.asyncio
-async def test_generate_hash_file_requires_signature_file(tmp_path: pathlib.Path):
+async def test_generate_hash_file_without_signature_file(tmp_path: pathlib.Path):
     rel_path = pathlib.Path("artifact.tar.gz")
     (tmp_path / rel_path).write_bytes(b"payload")
     state_path = safe.StatePath(tmp_path)
     old_revision = SimpleNamespace(safe_number=safe.RevisionNumber("00001"))
+    captured: dict[str, object] = {}
 
     async def create_revision_with_quarantine(
         project_key: safe.ProjectKey,
@@ -146,23 +146,40 @@ async def test_generate_hash_file_requires_signature_file(tmp_path: pathlib.Path
     ) -> None:
         if modify is None:
             raise RuntimeError("Expected modify callback")
-        await modify(state_path, old_revision)
+        captured["path_provenance"] = await modify(state_path, old_revision)
 
     participant = _make_participant(create_revision_with_quarantine)
 
-    with mock.patch.object(
-        release,
-        "_signature_provenance_metadata_for",
-        new=mock.AsyncMock(),
-    ) as signature_metadata_mock:
-        with pytest.raises(datatypes.FailedError, match=r"requires a detached OpenPGP signature"):
-            await participant.generate_hash_file(
-                safe.ProjectKey("proj"),
-                safe.VersionKey("1.0"),
-                rel_path,
-            )
+    with (
+        mock.patch.object(
+            release,
+            "_signature_provenance_metadata_for",
+            new=mock.AsyncMock(),
+        ) as signature_metadata_mock,
+        mock.patch.object(
+            release.hashes,
+            "compute_sha512_and_content_hash",
+            new=mock.AsyncMock(return_value=("sha512hex", "blake3:source")),
+        ),
+    ):
+        await participant.generate_hash_file(
+            safe.ProjectKey("proj"),
+            safe.VersionKey("1.0"),
+            rel_path,
+        )
 
     assert signature_metadata_mock.await_count == 0
+    assert (tmp_path / "artifact.tar.gz.sha512").read_text() == "sha512hex  artifact.tar.gz\n"
+    assert captured["path_provenance"] == {
+        safe.RelPath("artifact.tar.gz.sha512"): attestable.ProvenanceV2(
+            generator=attestable.GeneratorV2.SHA512_FROM_CONTENT,
+            metadata={
+                "initiated_by": "alice",
+                "source_content_hashes": {"artifact.tar.gz": "blake3:source"},
+                "source_paths": ["artifact.tar.gz"],
+            },
+        )
+    }
 
 
 @pytest.mark.asyncio
@@ -203,7 +220,7 @@ async def test_signature_provenance_metadata_for_filters_unknown_fields():
 
 
 @pytest.mark.asyncio
-async def test_signature_provenance_metadata_for_requires_completed_check():
+async def test_signature_provenance_metadata_for_without_completed_check():
     parent_revision = SimpleNamespace(safe_number=safe.RevisionNumber("00005"))
     session = CheckResultSession([])
 
@@ -211,30 +228,30 @@ async def test_signature_provenance_metadata_for_requires_completed_check():
         mock.patch.object(release.db, "session", return_value=session),
         mock.patch.object(release.log, "info") as log_info_mock,
     ):
-        with pytest.raises(datatypes.FailedError, match=r"has not completed yet"):
-            await release._signature_provenance_metadata_for(
-                project_key=safe.ProjectKey("proj"),
-                version_key=safe.VersionKey("1.0"),
-                parent_revision=parent_revision,
-                signature_rel_path=pathlib.Path("artifact.tar.gz.asc"),
-            )
-    assert log_info_mock.call_count == 1
-    assert "waiting for signature verification" in log_info_mock.call_args.args[0]
-
-
-@pytest.mark.asyncio
-async def test_signature_provenance_metadata_for_requires_parent_revision():
-    with pytest.raises(datatypes.FailedError, match=r"requires a parent revision"):
-        await release._signature_provenance_metadata_for(
+        metadata = await release._signature_provenance_metadata_for(
             project_key=safe.ProjectKey("proj"),
             version_key=safe.VersionKey("1.0"),
-            parent_revision=None,
+            parent_revision=parent_revision,
             signature_rel_path=pathlib.Path("artifact.tar.gz.asc"),
         )
+    assert metadata is None
+    assert log_info_mock.call_count == 1
+    assert "proceeding without signature provenance" in log_info_mock.call_args.args[0]
+
+
+@pytest.mark.asyncio
+async def test_signature_provenance_metadata_for_without_parent_revision():
+    metadata = await release._signature_provenance_metadata_for(
+        project_key=safe.ProjectKey("proj"),
+        version_key=safe.VersionKey("1.0"),
+        parent_revision=None,
+        signature_rel_path=pathlib.Path("artifact.tar.gz.asc"),
+    )
+    assert metadata is None
 
 
 @pytest.mark.asyncio
-async def test_signature_provenance_metadata_for_requires_successful_check():
+async def test_signature_provenance_metadata_for_without_successful_check():
     parent_revision = SimpleNamespace(safe_number=safe.RevisionNumber("00005"))
     result = SimpleNamespace(
         message="No valid signature found",
@@ -248,13 +265,13 @@ async def test_signature_provenance_metadata_for_requires_successful_check():
         mock.patch.object(release.db, "session", return_value=session),
         mock.patch.object(release.log, "info") as log_info_mock,
     ):
-        with pytest.raises(datatypes.FailedError, match=r"failed: No valid signature found"):
-            await release._signature_provenance_metadata_for(
-                project_key=safe.ProjectKey("proj"),
-                version_key=safe.VersionKey("1.0"),
-                parent_revision=parent_revision,
-                signature_rel_path=pathlib.Path("artifact.tar.gz.asc"),
-            )
+        metadata = await release._signature_provenance_metadata_for(
+            project_key=safe.ProjectKey("proj"),
+            version_key=safe.VersionKey("1.0"),
+            parent_revision=parent_revision,
+            signature_rel_path=pathlib.Path("artifact.tar.gz.asc"),
+        )
+    assert metadata is None
     assert log_info_mock.call_count == 1
     assert "status=concern" in log_info_mock.call_args.args[0]
 


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