You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tooling.apache.org by ar...@apache.org on 2026/08/11 13:06:44 UTC

(tooling-trusted-releases) branch arm updated: #1491 - add git commit hash form

This is an automated email from the ASF dual-hosted git repository.

alitheg pushed a commit to branch arm
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git


The following commit(s) were added to refs/heads/arm by this push:
     new b36897c6 #1491 - add git commit hash form
b36897c6 is described below

commit b36897c6046cae84d82f6a10aeec06400fd990ed
Author: Alastair McFarlane <ar...@apache.org>
AuthorDate: Tue Aug 11 14:06:11 2026 +0100

    #1491 - add git commit hash form
---
 atr/attestable.py                               | 20 +++++++++++
 atr/construct.py                                |  3 ++
 atr/get/compose.py                              | 27 +++++++++++++++
 atr/models/safe.py                              | 27 +++++++++++++++
 atr/models/sql.py                               |  5 +++
 atr/post/compose.py                             | 41 +++++++++++++++++++---
 atr/shared/compose.py                           | 16 ++++++++-
 atr/ssh.py                                      |  1 +
 atr/storage/writers/release.py                  | 31 +++++++++++++++++
 atr/templates/check-selected-release-info.html  |  5 +++
 atr/templates/check-selected.html               |  3 ++
 migrations/versions/0119_2026.08.11_c4d2e1f0.py | 44 ++++++++++++++++++++++++
 tests/unit/test_construct_announce.py           | 45 ++++++++++++++++++++++++-
 tests/unit/test_safe_json.py                    |  1 +
 tests/unit/test_safe_types.py                   | 35 +++++++++++++++++++
 15 files changed, 298 insertions(+), 6 deletions(-)

diff --git a/atr/attestable.py b/atr/attestable.py
index 97239128..bcb4f1cf 100644
--- a/atr/attestable.py
+++ b/atr/attestable.py
@@ -298,6 +298,26 @@ async def latest_revision_number(
     return safe.RevisionNumber(numbers[-1])
 
 
+async def latest_github_tp_payload(
+    project_key: safe.ProjectKey, version_key: safe.VersionKey
+) -> github.TrustedPublisherPayload | None:
+    # Only Trusted Publishing uploads leave a .github-tp.json, and a later hand upload
+    # doesn't, so we walk back from the newest revision to the most recent one that did
+    directory = paths.get_attestable_dir() / str(project_key) / str(version_key)
+    suffix = ".github-tp.json"
+    try:
+        names = await aiofiles.os.listdir(directory)
+    except FileNotFoundError:
+        return None
+    stems = sorted(name.removesuffix(suffix) for name in names if name.endswith(suffix))
+    for stem in reversed(stems):
+        if (len(stem) == 5) and stem.isascii() and stem.isdigit():
+            payload = await github_tp_payload_read(project_key, version_key, safe.RevisionNumber(stem))
+            if payload is not None:
+                return payload
+    return None
+
+
 async def load(
     project_key: safe.ProjectKey,
     version_key: safe.VersionKey,
diff --git a/atr/construct.py b/atr/construct.py
index ee0c74f5..b0904c33 100644
--- a/atr/construct.py
+++ b/atr/construct.py
@@ -95,6 +95,7 @@ class _VoteSubjectValues(TypedDict):
 class _VoteValues(TypedDict):
     BUG_DATABASE: str
     CHECKLIST_URL: str
+    COMMIT: str
     COMMITTEE: str
     DURATION: str
     HOMEPAGE: str
@@ -122,6 +123,7 @@ TEMPLATE_DESCRIPTIONS: Final[dict[str, str]] = {
     "ATR_TALLY": "Vote tally block - URL, ballots, counts",
     "BUG_DATABASE": "Bug database URL",
     "CHECKLIST_URL": "URL to the release checklist",
+    "COMMIT": "Source commit hash, if recorded",
     "COMMITTEE": "Committee name",
     "DOWNLOAD_PAGE": "Download page URL",
     "DOWNLOAD_URL": "URL to download the release",
@@ -419,6 +421,7 @@ async def start_vote_subject_and_body(subject: str, body: str, options: StartVot
     body_values: _VoteValues = {
         "BUG_DATABASE": project.bug_database or "",
         "CHECKLIST_URL": checklist_url,
+        "COMMIT": release.commit_hash or "",
         "COMMITTEE": committee.display_name,
         "DURATION": str(options.vote_duration or "an unlimited number of"),
         "HOMEPAGE": project.homepage or "",
diff --git a/atr/get/compose.py b/atr/get/compose.py
index cc056ed1..1879d7ab 100644
--- a/atr/get/compose.py
+++ b/atr/get/compose.py
@@ -25,6 +25,7 @@ import htpy
 import quart
 from quart_wtf import utils
 
+import atr.attestable as attestable
 import atr.blueprints.get as get
 import atr.db as db
 import atr.db.interaction as interaction
@@ -38,6 +39,7 @@ import atr.models.sql as sql
 import atr.paths as paths
 import atr.post as post
 import atr.render as render
+import atr.shared as shared
 import atr.shared.activity as activity
 import atr.shared.draft as draft
 import atr.storage as storage
@@ -153,6 +155,7 @@ async def selected(
         submit_disabled=release.check_cache_key is None,
     )
     activity_form_html = await _activity_form_html(release, session)
+    commit_hash_form_html = await _commit_hash_form_html(release)
 
     has_files = await util.has_files(release)
 
@@ -231,6 +234,7 @@ async def selected(
         recheck_form=recheck_form,
         cache_reset_form=cache_reset_form,
         activity_form=activity_form_html,
+        commit_hash_form=commit_hash_form_html,
         csrf_input=str(form.csrf_input()),
         has_files=has_files,
         blocker_errors=blocker_errors,
@@ -271,6 +275,29 @@ async def _activity_form_html(release: sql.Release, session: web.Committer) -> s
     return str(activity_form)
 
 
+async def _commit_hash_form_html(release: sql.Release) -> str:
+    action = util.as_url(
+        post.compose.selected, project_key=release.safe_project_key, version_key=release.safe_version_key
+    )
+    payload = await attestable.latest_github_tp_payload(release.safe_project_key, release.safe_version_key)
+    if payload is not None:
+        # GitHub has attested the commit, so we show it locked rather than as an editable form
+        locked = htm.div(".mb-3")[
+            htpy.label(".form-label")["Commit hash"],
+            htpy.input(type="text", class_="form-control", value=payload.sha, readonly=True, disabled=True),
+            htm.div(".form-text")["Set by GitHub via Trusted Publishing; it cannot be changed here."],
+        ]
+        return str(locked)
+    commit_hash_form = await form.render(
+        model_cls=shared.compose.SetCommitHashForm,
+        action=action,
+        submit_label="Save commit hash",
+        submit_classes="btn-outline-primary",
+        defaults={"commit_hash": release.commit_hash or ""},
+    )
+    return str(commit_hash_form)
+
+
 def _banner_html(quarantine_pending: int, ongoing: int) -> str:
     is_are = "is" if (ongoing == 1) else "are"
     task_word = "task" if (ongoing == 1) else "tasks"
diff --git a/atr/models/safe.py b/atr/models/safe.py
index afdabd78..7b447e4f 100644
--- a/atr/models/safe.py
+++ b/atr/models/safe.py
@@ -25,6 +25,7 @@ from typing import Annotated, Any, Final
 import pydantic
 
 _ALPHANUM: Final = frozenset(string.ascii_letters + string.digits + "-")
+_HEX_LOWER: Final = frozenset(string.hexdigits.lower())
 _PROJECT_CHARS: Final = _ALPHANUM
 _ASF_UID_CHARS: Final = frozenset(string.ascii_lowercase + string.digits + "-_")
 _NUMERIC: Final = frozenset(string.digits)
@@ -206,6 +207,19 @@ class AsfUid(SafeType):
             raise ValueError("ASF UID must start with a lowercase letter")
 
 
+class CommitHash(SafeType):
+    """A source commit hash, validated as lowercase hexadecimal of a git-like length."""
+
+    @classmethod
+    def _valid_chars(cls) -> frozenset[str]:
+        return _HEX_LOWER
+
+    def _additional_validations(self, value: str) -> None:
+        # Wide enough for a short hash through to a full SHA-256 object name
+        if not (7 <= len(value) <= 64):
+            raise ValueError("A commit hash must be between 7 and 64 hexadecimal characters")
+
+
 class CommitteeKey(Alphanumeric):
     pass
 
@@ -333,6 +347,14 @@ def _empty_to_none(v: object) -> object:
     return v
 
 
+def _lower_or_none(v: object) -> object:
+    """Fold a commit hash to lowercase, treating a blank string as absent."""
+    if isinstance(v, str):
+        stripped = v.strip().lower()
+        return stripped or None
+    return v
+
+
 def _strip_slashes_or_none(v: object) -> object:
     """Strip leading/trailing slashes from a path string; return None if only slashes."""
     if isinstance(v, str):
@@ -348,6 +370,11 @@ type OptionalAlphanumeric = Annotated[
     pydantic.BeforeValidator(_strip_slashes_or_none),
 ]
 
+type OptionalCommitHash = Annotated[
+    CommitHash | None,
+    pydantic.BeforeValidator(_lower_or_none),
+]
+
 type OptionalOwnerNamespace = Annotated[
     OwnerNamespace | None,
     pydantic.BeforeValidator(_strip_slashes_or_none),
diff --git a/atr/models/sql.py b/atr/models/sql.py
index 4855157b..37c61a4d 100644
--- a/atr/models/sql.py
+++ b/atr/models/sql.py
@@ -1469,6 +1469,11 @@ class Release(sqlmodel.SQLModel, table=True):
     # default; an empty string is a deliberate choice of the distribution root.
     download_path_suffix: str | None = sqlmodel.Field(default=None, **example("incubator/example/0.0.1"))
 
+    # The source commit hash for this release. Auto-filled from the Trusted Publishing
+    # attestation when a workflow uploads, or recorded by hand otherwise. Null until we
+    # know it.
+    commit_hash: str | None = sqlmodel.Field(default=None, **example("1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b"))
+
     # M-1: Release -> Project
     # 1-M: Project -> [Release]
     project_key: str = sqlmodel.Field(foreign_key="project.key", **example("example"))
diff --git a/atr/post/compose.py b/atr/post/compose.py
index 138957c3..3603cb55 100644
--- a/atr/post/compose.py
+++ b/atr/post/compose.py
@@ -19,9 +19,12 @@ from typing import Literal
 
 import quart
 
+import atr.attestable as attestable
 import atr.blueprints.post as post
 import atr.errors as errors
+import atr.get as get
 import atr.log as log
+import atr.mapping as mapping
 import atr.models.safe as safe
 import atr.shared as shared
 import atr.storage as storage
@@ -34,14 +37,45 @@ async def selected(
     _compose: Literal["compose"],
     project_key: safe.ProjectKey,
     version_key: safe.VersionKey,
-    move_form: shared.compose.MoveFileForm,
+    compose_form: shared.compose.ComposeForm,
 ) -> tuple[web.QuartResponse, int] | web.WerkzeugResponse:
     """
     URL: /compose/<project_key>/<version_key>
     """
-    respond = _respond_helper(session, project_key, version_key)
+    match compose_form:
+        case shared.compose.MoveFileForm():
+            respond = _respond_helper(session, project_key, version_key)
+            return await _move_file_to_revision(compose_form, session, project_key, version_key, respond)
+        case shared.compose.SetCommitHashForm():
+            return await _set_commit_hash(session, project_key, version_key, compose_form.commit_hash)
+
 
-    return await _move_file_to_revision(move_form, session, project_key, version_key, respond)
+async def _set_commit_hash(
+    session: web.Committer,
+    project_key: safe.ProjectKey,
+    version_key: safe.VersionKey,
+    commit_hash: safe.CommitHash | None,
+) -> web.WerkzeugResponse:
+    submitted = str(commit_hash) if (commit_hash is not None) else None
+    # Trusted Publishing is authoritative, so a hand-entered value has to agree with its attestation
+    payload = await attestable.latest_github_tp_payload(project_key, version_key)
+    if (payload is not None) and (submitted != payload.sha):
+        return await session.redirect(
+            get.compose.selected,
+            project_key=str(project_key),
+            version_key=str(version_key),
+            error="The commit hash was set via Trusted Publishing and cannot be edited.",
+        )
+    try:
+        async with storage.write(session) as write:
+            wacp = await write.as_project_committee_participant(project_key)
+            release = await wacp.release.set_commit_hash(project_key, version_key, submitted)
+    except storage.AccessError as e:
+        return await session.redirect(
+            get.compose.selected, project_key=str(project_key), version_key=str(version_key), error=str(e)
+        )
+    await quart.flash("Commit hash updated", "success")
+    return await mapping.release_as_redirect(session, release)
 
 
 async def _move_file_to_revision(
@@ -92,7 +126,6 @@ def _respond_helper(
     session: web.Committer, project_key: safe.ProjectKey, version_key: safe.VersionKey
 ) -> shared.compose.Respond:
     """Create a response helper function for the compose route."""
-    import atr.get as get
 
     async def respond(
         http_status: int,
diff --git a/atr/shared/compose.py b/atr/shared/compose.py
index 31af9ef1..c9a4ef90 100644
--- a/atr/shared/compose.py
+++ b/atr/shared/compose.py
@@ -16,7 +16,7 @@
 # under the License.
 
 from collections.abc import Awaitable, Callable
-from typing import Literal
+from typing import Annotated, Literal
 
 import pydantic
 
@@ -27,6 +27,7 @@ import atr.web as web
 type Respond = Callable[[int, str], Awaitable[tuple[web.QuartResponse, int] | web.WerkzeugResponse]]
 
 type MOVE_FILE = Literal["MOVE_FILE"]
+type SET_COMMIT_HASH = Literal["SET_COMMIT_HASH"]
 
 
 class MoveFileForm(form.Form):
@@ -45,3 +46,16 @@ class MoveFileForm(form.Form):
             if source.parent == target_dir_path:
                 raise ValueError(f"Target directory cannot be the same as the source directory for {source.name}.")
         return self
+
+
+class SetCommitHashForm(form.Form):
+    variant: SET_COMMIT_HASH = form.value(SET_COMMIT_HASH)
+    commit_hash: safe.OptionalCommitHash = form.label(
+        "Commit hash",
+        "The source commit hash for this release. Disabled when GitHub has set it via Trusted Publishing.",
+        widget=form.Widget.TEXT,
+        default="",
+    )
+
+
+type ComposeForm = Annotated[MoveFileForm | SetCommitHashForm, form.DISCRIMINATOR]
diff --git a/atr/ssh.py b/atr/ssh.py
index 7ed158b1..1c29bfce 100644
--- a/atr/ssh.py
+++ b/atr/ssh.py
@@ -693,6 +693,7 @@ async def _step_07b_process_validated_rsync_write(
                     await attestable.github_tp_payload_write(
                         project_key, version_key, result.safe_number, github_payload
                     )
+                    await wacp.release.set_commit_hash(project_key, version_key, github_payload.sha)
                 log.info(f"rsync upload successful for revision {result.number}")
                 host = config.get().APP_HOST
                 message = f"\nATR: Created revision {result.number} of {project_key} {version_key}\n"
diff --git a/atr/storage/writers/release.py b/atr/storage/writers/release.py
index daf1fe57..059f1ed6 100644
--- a/atr/storage/writers/release.py
+++ b/atr/storage/writers/release.py
@@ -449,6 +449,37 @@ class CommitteeParticipant(FoundationCommitter):
         )
         return release
 
+    async def set_commit_hash(
+        self,
+        project_key: safe.ProjectKey,
+        version_key: safe.VersionKey,
+        commit_hash: str | None,
+    ) -> sql.Release:
+        # Shared by the Trusted Publishing auto-fill and the manual recording form
+        release = await self.__data.release(
+            project_key=str(project_key), version=str(version_key), _committee=True, _project=True
+        ).demand(storage.AccessError(f"Release '{project_key!s} {version_key!s}' not found.", status=404))
+        storage.ensure_project_active(release.project)
+        self.__write.ensure_release_writable(release)
+        previous_commit_hash = release.commit_hash
+        via = sql.validate_instrumented_attribute
+        result = await self.__data.execute(
+            sqlmodel.update(sql.Release).where(via(sql.Release.key) == release.key).values(commit_hash=commit_hash)
+        )
+        if getattr(result, "rowcount", 0) != 1:
+            await self.__data.rollback()
+            raise storage.AccessError("The release state has changed, please refresh and try again", status=409)
+        await self.__data.refresh(release)
+        await self.__data.commit()
+        self.__write_as.append_to_audit_log(
+            asf_uid=self.__asf_uid,
+            project_key=str(project_key),
+            version=str(version_key),
+            previous_commit_hash=previous_commit_hash,
+            commit_hash=commit_hash,
+        )
+        return release
+
     async def delete(
         self,
         project_key: safe.ProjectKey,
diff --git a/atr/templates/check-selected-release-info.html b/atr/templates/check-selected-release-info.html
index 6203e6fe..94888557 100644
--- a/atr/templates/check-selected-release-info.html
+++ b/atr/templates/check-selected-release-info.html
@@ -40,6 +40,11 @@
                target="_blank">Vote thread</a>
           </p>
         {% endif %}
+        {% if release.commit_hash %}
+          <p>
+            <strong>Commit hash:</strong> <code>{{ release.commit_hash }}</code>
+          </p>
+        {% endif %}
       </div>
     </div>
     <div class="d-flex flex-wrap gap-2">
diff --git a/atr/templates/check-selected.html b/atr/templates/check-selected.html
index b4d94484..628769e2 100644
--- a/atr/templates/check-selected.html
+++ b/atr/templates/check-selected.html
@@ -157,6 +157,9 @@
     {% if activity_form %}
       <div class="mb-2">{{ activity_form|safe }}</div>
     {% endif %}
+    <h3 id="commit-hash" class="mt-4">Commit hash</h3>
+    <p>Record the source commit hash for this release.</p>
+    <div class="mb-2">{{ commit_hash_form|safe }}</div>
     {% if is_viewing_as_admin_fn(current_user.uid) %}
       <h3 id="cache-control" class="mt-4">Check cache control</h3>
       <p>Each button creates a new revision and restarts all checks.</p>
diff --git a/migrations/versions/0119_2026.08.11_c4d2e1f0.py b/migrations/versions/0119_2026.08.11_c4d2e1f0.py
new file mode 100644
index 00000000..4cc8182d
--- /dev/null
+++ b/migrations/versions/0119_2026.08.11_c4d2e1f0.py
@@ -0,0 +1,44 @@
+# 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.
+
+"""Add a per-release source commit hash
+
+Revision ID: 0119_2026.08.11_c4d2e1f0
+Revises: 0118_2026.08.10_01f16916
+Create Date: 2026-08-11 11:34:07.518642+00:00
+"""
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+# Revision identifiers, used by Alembic
+revision: str = "0119_2026.08.11_c4d2e1f0"
+down_revision: str | None = "0118_2026.08.10_01f16916"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+    with op.batch_alter_table("release", schema=None) as batch_op:
+        batch_op.add_column(sa.Column("commit_hash", sa.String(), nullable=True))
+
+
+def downgrade() -> None:
+    with op.batch_alter_table("release", schema=None) as batch_op:
+        batch_op.drop_column("commit_hash")
diff --git a/tests/unit/test_construct_announce.py b/tests/unit/test_construct_announce.py
index 3fd1514f..08511a1b 100644
--- a/tests/unit/test_construct_announce.py
+++ b/tests/unit/test_construct_announce.py
@@ -189,7 +189,7 @@ async def test_start_vote_subject_and_body_uses_canonical_keys_url(
         homepage=None,
         repositories=[],
     )
-    release = SimpleNamespace(key="myproject-1.0.0", committee=committee, project=project)
+    release = SimpleNamespace(key="myproject-1.0.0", committee=committee, project=project, commit_hash=None)
     revision = SimpleNamespace(number="1", tag=None)
 
     async def no_release_policy(_data, _project_key):
@@ -218,6 +218,49 @@ async def test_start_vote_subject_and_body_uses_canonical_keys_url(
     assert body == expected_url
 
 
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    ("commit_hash", "expected"),
+    [("1a2b3c4d5e6f", "1a2b3c4d5e6f"), (None, "")],
+)
+async def test_start_vote_body_renders_commit_token(monkeypatch, commit_hash: str | None, expected: str) -> None:
+    committee = SimpleNamespace(key="myproject", is_podling=False, 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, commit_hash=commit_hash)
+    revision = SimpleNamespace(number="1", tag=None)
+
+    async def no_release_policy(_data, _project_key):
+        return None
+
+    monkeypatch.setattr(construct.config.get(), "APP_HOST", "atr.example.invalid")
+    monkeypatch.setattr(construct.config.get(), "SVN_PUBLISH_URL", "https://svn.example.invalid/repos/dist/atr")
+    monkeypatch.setattr(construct.config.get(), "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(
+        "",
+        "{{COMMIT}}",
+        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
+
+
 def test_substitute_does_not_rescan_replacement_values() -> None:
     result = construct._substitute(
         "{{PROJECT_NAME}} {{VERSION}} {{UNKNOWN}}",
diff --git a/tests/unit/test_safe_json.py b/tests/unit/test_safe_json.py
index 12ab7fb4..0cd404c0 100644
--- a/tests/unit/test_safe_json.py
+++ b/tests/unit/test_safe_json.py
@@ -33,6 +33,7 @@ _SAFE_TYPE_EXAMPLES: dict[type[safe.SafeType], str] = {
     safe.Alphanumeric: "abc-123",
     safe.OwnerNamespace: "com.example",
     safe.AsfUid: "user1",
+    safe.CommitHash: "1a2b3c4d",
     safe.CommitteeKey: "tooling",
     safe.Numeric: "12345",
     safe.ProjectKey: "tooling",
diff --git a/tests/unit/test_safe_types.py b/tests/unit/test_safe_types.py
index 2420ccea..52e95887 100644
--- a/tests/unit/test_safe_types.py
+++ b/tests/unit/test_safe_types.py
@@ -14,6 +14,7 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
+import pydantic
 import pytest
 
 import atr.models.safe as safe
@@ -71,3 +72,37 @@ def test_safe_version_types_accept_valid_version(cls: type[safe.Alphanumeric]):
 def test_safe_version_types_reject_too_long():
     with pytest.raises(ValueError, match="at most"):
         safe.VersionKey("1" * (safe.MAX_VERSION_LENGTH + 1))
+
+
+@pytest.mark.parametrize("value", ["abc1234", "0123456789abcdef", "a" * 7, "f" * 64])
+def test_commit_hash_accepts_valid_hex(value: str):
+    assert str(safe.CommitHash(value)) == value
+
+
+@pytest.mark.parametrize("bad", ["abc", "abcdef", "a" * 65])
+def test_commit_hash_rejects_bad_length(bad: str):
+    with pytest.raises(ValueError, match="7 and 64"):
+        safe.CommitHash(bad)
+
+
+@pytest.mark.parametrize("bad", ["ghijklm", "xyz1234", "abc 123"])
+def test_commit_hash_rejects_non_hex(bad: str):
+    with pytest.raises(ValueError):
+        safe.CommitHash(bad)
+
+
+@pytest.mark.parametrize(("value", "expected"), [("ABC1234", "abc1234"), ("DeadBeef12", "deadbeef12")])
+def test_optional_commit_hash_lowercases(value: str, expected: str):
+    result = pydantic.TypeAdapter(safe.OptionalCommitHash).validate_python(value)
+    assert str(result) == expected
+
+
+@pytest.mark.parametrize("value", ["", "   "])
+def test_optional_commit_hash_blank_is_none(value: str):
+    assert pydantic.TypeAdapter(safe.OptionalCommitHash).validate_python(value) is None
+
+
+@pytest.mark.parametrize("bad", ["abc", "nothex1", "a" * 65])
+def test_optional_commit_hash_rejects_invalid(bad: str):
+    with pytest.raises(ValueError):
+        pydantic.TypeAdapter(safe.OptionalCommitHash).validate_python(bad)


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