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