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/08/10 19:06:03 UTC

(tooling-trusted-releases) branch sbp updated: Save signature hashes before publication

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


The following commit(s) were added to refs/heads/sbp by this push:
     new b590f788 Save signature hashes before publication
b590f788 is described below

commit b590f7886d9562f061d20eb94ecf038cfaeeabb4
Author: Sean B. Palmer <se...@miscoranda.com>
AuthorDate: Mon Aug 10 20:05:13 2026 +0100

    Save signature hashes before publication
---
 atr/db/__init__.py                              |  3 ++
 atr/models/sql.py                               |  5 ++
 atr/storage/writers/announce.py                 |  5 ++
 atr/storage/writers/catalogue.py                | 32 ++++++++++-
 migrations/versions/0118_2026.08.10_b89e555b.py | 46 ++++++++++++++++
 tests/unit/test_announce_artifact_rows.py       | 71 +++++++++++++++++++++++++
 tests/unit/test_catalogue_import_apply.py       | 28 ++++++++++
 7 files changed, 189 insertions(+), 1 deletion(-)

diff --git a/atr/db/__init__.py b/atr/db/__init__.py
index 04e2deb6..07e2543c 100644
--- a/atr/db/__init__.py
+++ b/atr/db/__init__.py
@@ -206,6 +206,7 @@ class Session(sqlalchemy.ext.asyncio.AsyncSession):
         release_key: Opt[str | None] = NOT_SET,
         key_fingerprint: Opt[str | None] = NOT_SET,
         signature_path: Opt[str | None] = NOT_SET,
+        signature_sha3_256: Opt[str | None] = NOT_SET,
         checksum_path: Opt[str | None] = NOT_SET,
         classification: Opt[str | None] = NOT_SET,
         svn_revision: Opt[int | None] = NOT_SET,
@@ -225,6 +226,8 @@ class Session(sqlalchemy.ext.asyncio.AsyncSession):
             query = query.where(sql.Artifact.key_fingerprint == key_fingerprint)
         if is_defined(signature_path):
             query = query.where(sql.Artifact.signature_path == signature_path)
+        if is_defined(signature_sha3_256):
+            query = query.where(sql.Artifact.signature_sha3_256 == signature_sha3_256)
         if is_defined(checksum_path):
             query = query.where(sql.Artifact.checksum_path == checksum_path)
         if is_defined(classification):
diff --git a/atr/models/sql.py b/atr/models/sql.py
index c55f30d9..98069a7a 100644
--- a/atr/models/sql.py
+++ b/atr/models/sql.py
@@ -2086,6 +2086,11 @@ class Artifact(sqlmodel.SQLModel, table=True):
     )
     # Path to the .asc detached signature file
     signature_path: str | None = sqlmodel.Field(default=None, **example("apache-example-0.0.1.tar.gz.asc"))
+    signature_sha3_256: str | None = sqlmodel.Field(
+        default=None,
+        index=True,
+        **example("da43c7a4d3d1e8408d3f0ac0f624a1e1f6c0d1c1a4d1a3b31843b6d15c2f2f2f"),
+    )
     # Path to the strongest available checksum file (SHA-512 preferred over SHA-256 over MD5)
     checksum_path: str | None = sqlmodel.Field(default=None, **example("apache-example-0.0.1.tar.gz.sha512"))
     # Path to the paired CycloneDX SBOM, if one rides alongside the artifact (.cdx.json preferred over .cdx.xml)
diff --git a/atr/storage/writers/announce.py b/atr/storage/writers/announce.py
index 8f0ecdb8..e692e2a8 100644
--- a/atr/storage/writers/announce.py
+++ b/atr/storage/writers/announce.py
@@ -35,6 +35,7 @@ import atr.config as config
 import atr.construct as construct
 import atr.db as db
 import atr.db.interaction as interaction
+import atr.hashes as hashes
 import atr.log as log
 import atr.mail as mail
 import atr.models.args as args
@@ -675,6 +676,9 @@ class ReleaseManager(CommitteeParticipant):
                 if signature_path is not None
                 else None
             )
+            signature_sha3 = (
+                await hashes.file_sha3(str(finished_path / signature_path)) if (signature_path is not None) else None
+            )
             self.__data.add(
                 sql.Artifact(
                     project_key=release.project_key,
@@ -683,6 +687,7 @@ class ReleaseManager(CommitteeParticipant):
                     release_key=release.key,
                     key_fingerprint=fingerprint,
                     signature_path=signature_path,
+                    signature_sha3_256=signature_sha3,
                     checksum_path=checksum_path,
                     sbom_path=sbom_path,
                     classification=classifications.get(rel),
diff --git a/atr/storage/writers/catalogue.py b/atr/storage/writers/catalogue.py
index 7a0fce2a..e37ceda7 100644
--- a/atr/storage/writers/catalogue.py
+++ b/atr/storage/writers/catalogue.py
@@ -354,6 +354,32 @@ class FoundationAdmin:
             chunk = pks[start : start + _DELETE_CHUNK]
             await self.__data.execute(sqlmodel.delete(sql.Artifact).where(identity.in_(chunk)))
 
+    async def _preserved_artifact_facts(
+        self, diff: catalogue_diff.CatalogueDiff
+    ) -> dict[tuple[str, str, str], tuple[str | None, int | None, str | None]]:
+        pks = [
+            (str(add.project_key), str(add.version), str(add.artifact_path))
+            for add in diff.adds
+            if isinstance(add, catalogue_rows.ArtifactRow)
+        ]
+        if not pks:
+            return {}
+        via = sql.validate_instrumented_attribute
+        identity = sqlalchemy.tuple_(
+            via(sql.Artifact.project_key), via(sql.Artifact.version), via(sql.Artifact.artifact_path)
+        )
+        facts: dict[tuple[str, str, str], tuple[str | None, int | None, str | None]] = {}
+        for start in range(0, len(pks), _DELETE_CHUNK):
+            chunk = pks[start : start + _DELETE_CHUNK]
+            rows = (await self.__data.execute(sqlmodel.select(sql.Artifact).where(identity.in_(chunk)))).scalars()
+            for row in rows:
+                facts[(row.project_key, row.version, row.artifact_path)] = (
+                    row.key_fingerprint,
+                    row.svn_revision,
+                    row.signature_sha3_256,
+                )
+        return facts
+
     async def _resolve_super_projects(self, project_keys: list[str]) -> None:
         # A sub-project hangs under the longest project key in its committee that prefixes its own,
         # which is the rule the project creation path applies. The CSV does not carry the column
@@ -450,6 +476,7 @@ class FoundationAdmin:
 
     async def _apply_diff(self, diff: catalogue_diff.CatalogueDiff) -> None:
         await self.__data.execute(sqlalchemy.text("PRAGMA defer_foreign_keys=ON"))
+        preserved = await self._preserved_artifact_facts(diff)
         # Deletions run first, so the committee is cleared before the files are re-added and a row
         # can reuse a key it has just released. A deleted project leaves its surviving sub-projects
         # with no super pointer, and they hang under whatever the files put back in its place
@@ -467,7 +494,10 @@ class FoundationAdmin:
         await self.__data.flush()
         for add in diff.adds:
             if isinstance(add, catalogue_rows.ArtifactRow):
-                self.__data.add(add.to_sql())
+                row = add.to_sql()
+                if facts := preserved.get((row.project_key, row.version, row.artifact_path)):
+                    row.key_fingerprint, row.svn_revision, row.signature_sha3_256 = facts
+                self.__data.add(row)
         await self._reassign_cycles_for_added_releases(diff)
         # Artifacts repoint first: a repointed release takes its artifacts by project and version,
         # so an artifact the file sends elsewhere has to leave before the release takes it along
diff --git a/migrations/versions/0118_2026.08.10_b89e555b.py b/migrations/versions/0118_2026.08.10_b89e555b.py
new file mode 100644
index 00000000..1dd8c97e
--- /dev/null
+++ b/migrations/versions/0118_2026.08.10_b89e555b.py
@@ -0,0 +1,46 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Record the SHA3-256 digest of each signature file on its artifact row
+
+Revision ID: 0118_2026.08.10_b89e555b
+Revises: 0117_2026.08.03_7768fea7
+Create Date: 2026-08-10 18:01:26.580536+00:00
+"""
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+# Revision identifiers, used by Alembic
+revision: str = "0118_2026.08.10_b89e555b"
+down_revision: str | None = "0117_2026.08.03_7768fea7"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+    with op.batch_alter_table("artifact", schema=None) as batch_op:
+        batch_op.add_column(sa.Column("signature_sha3_256", sa.String(), nullable=True))
+        batch_op.create_index(batch_op.f("ix_artifact_signature_sha3_256"), ["signature_sha3_256"], unique=False)
+
+
+def downgrade() -> None:
+    with op.batch_alter_table("artifact", schema=None) as batch_op:
+        batch_op.drop_index(batch_op.f("ix_artifact_signature_sha3_256"))
+        batch_op.drop_column("signature_sha3_256")
diff --git a/tests/unit/test_announce_artifact_rows.py b/tests/unit/test_announce_artifact_rows.py
new file mode 100644
index 00000000..5729fbf6
--- /dev/null
+++ b/tests/unit/test_announce_artifact_rows.py
@@ -0,0 +1,71 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import datetime
+import hashlib
+import pathlib
+import types
+import unittest.mock as mock
+
+import pytest
+
+import atr.models.safe as safe
+import atr.models.sql as sql
+import atr.storage.writers.announce as announce_writer
+
+
+@pytest.mark.asyncio
+async def test_write_artifact_rows_records_signature_digest(
+    tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+    (tmp_path / "artifact.tar.gz").write_bytes(b"artifact bytes")
+    signature_bytes = b"signature bytes"
+    (tmp_path / "artifact.tar.gz.asc").write_bytes(signature_bytes)
+
+    rows: list[sql.Artifact] = []
+    data = mock.MagicMock()
+    data.add = mock.MagicMock(side_effect=rows.append)
+    data.release_file_classifications_at = mock.AsyncMock(return_value={})
+    data.revision = mock.MagicMock(return_value=types.SimpleNamespace(get=mock.AsyncMock(return_value=None)))
+    monkeypatch.setattr(announce_writer.interaction, "check_results_for_revision", mock.AsyncMock(return_value=[]))
+
+    release = types.SimpleNamespace(
+        project_key="foo",
+        version="1.0",
+        key="foo-1.0",
+        safe_project_key=safe.ProjectKey("foo"),
+        safe_version_key=safe.VersionKey("1.0"),
+    )
+    committee = types.SimpleNamespace(is_podling=False, key="tooling")
+    writer = object.__new__(announce_writer.ReleaseManager)
+    writer._ReleaseManager__data = data
+    write_rows = getattr(writer, "_ReleaseManager__write_artifact_rows")
+
+    await write_rows(
+        release,
+        committee,
+        safe.StatePath(tmp_path),
+        safe.RevisionNumber("00003"),
+        42,
+        None,
+        datetime.datetime(2026, 8, 10, tzinfo=datetime.UTC),
+    )
+
+    assert [row.artifact_path for row in rows] == ["artifact.tar.gz"]
+    assert rows[0].signature_path == "artifact.tar.gz.asc"
+    assert rows[0].signature_sha3_256 == hashlib.sha3_256(signature_bytes).hexdigest()
+    assert rows[0].svn_revision == 42
diff --git a/tests/unit/test_catalogue_import_apply.py b/tests/unit/test_catalogue_import_apply.py
index d1158f60..ebddbba4 100644
--- a/tests/unit/test_catalogue_import_apply.py
+++ b/tests/unit/test_catalogue_import_apply.py
@@ -369,6 +369,34 @@ async def test_replace_deletes_artifacts_the_file_does_not_list(sessionmaker) ->
         assert await data.get(sql.Release, "alpha-one-1.0.0") is not None
 
 
+@pytest.mark.asyncio
+async def test_replace_preserves_computed_artifact_facts(sessionmaker) -> None:
+    async with sessionmaker() as data:
+        await _seed_two_projects_with_release(data, {"alpha-one": "1.0.0"})
+        data.add(
+            sql.Artifact(
+                project_key="alpha-one",
+                version="1.0.0",
+                artifact_path="keep.tgz",
+                release_key="alpha-one-1.0.0",
+                download_path_suffix="alpha/keep.tgz",
+                svn_revision=1234,
+                signature_sha3_256="ab" * 32,
+            )
+        )
+        await data.commit()
+
+        keep = _artifact_row("alpha-one", "1.0.0", "keep.tgz")
+        keep["download_path_suffix"] = "alpha/keep.tgz"
+        await _writer(data).import_catalogue_csvs({"artifacts": [keep]}, "alpha", catalogue_diff.Mode.REPLACE)
+
+        data.expire_all()
+        row = await data.get(sql.Artifact, ("alpha-one", "1.0.0", "keep.tgz"))
+        assert row is not None
+        assert row.svn_revision == 1234
+        assert row.signature_sha3_256 == "ab" * 32
+
+
 @pytest.mark.asyncio
 async def test_replace_reparents_a_sub_project_it_does_not_delete(sessionmaker) -> None:
     # Deleting a project clears the super pointer of everything beneath it, so a sub-project the


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