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

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


The following commit(s) were added to refs/heads/main by this push:
     new 08717d7  Update outdated tool scanners and add ATR tool metadata to 
the SBOM. Reference existing BOM versions in augment tasks.
08717d7 is described below

commit 08717d770e26c77e8a832572f749c018586d38e1
Author: Alastair McFarlane <[email protected]>
AuthorDate: Fri Dec 19 14:23:30 2025 +0000

    Update outdated tool scanners and add ATR tool metadata to the SBOM. 
Reference existing BOM versions in augment tasks.
---
 .pre-commit-config.yaml               |   4 +-
 atr/get/sbom.py                       | 114 ++++++++++++++++++++++++++--------
 atr/models/results.py                 |  11 +++-
 atr/sbom/__init__.py                  |   3 +-
 atr/sbom/cli.py                       |   2 +-
 atr/sbom/maven.py                     |  59 +-----------------
 atr/sbom/models/__init__.py           |  14 +----
 atr/sbom/models/bom.py                |  10 +++
 atr/sbom/models/{maven.py => tool.py} |  11 +++-
 atr/sbom/osv.py                       |   1 -
 atr/sbom/tool.py                      | 104 +++++++++++++++++++++++++++++++
 atr/sbom/utilities.py                 | 103 ++++++++++++++++++++++++------
 atr/tasks/sbom.py                     |  51 ++++++++-------
 pyproject.toml                        |   1 +
 uv.lock                               |  57 ++++++++++-------
 15 files changed, 375 insertions(+), 170 deletions(-)

diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 5539c1f..9b72a39 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -93,7 +93,7 @@ repos:
       types_or: ['css']
       args: ['--fix']
 - repo: https://github.com/woodruffw/zizmor-pre-commit
-  rev: v1.18.0
+  rev: v1.19.0
   hooks:
     - id: zizmor
       args: [--min-severity, low]
@@ -102,7 +102,7 @@ repos:
   hooks:
     - id: pip-audit
 - repo: https://github.com/oxc-project/mirrors-oxlint
-  rev: v1.33.0
+  rev: v1.34.0
   hooks:
     - id: oxlint
       name: lint JS files with Oxlint
diff --git a/atr/get/sbom.py b/atr/get/sbom.py
index 4a588e1..71c92fd 100644
--- a/atr/get/sbom.py
+++ b/atr/get/sbom.py
@@ -18,7 +18,7 @@
 from __future__ import annotations
 
 import json
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, Any
 
 import asfquart.base as base
 import cmarkgfm
@@ -65,6 +65,16 @@ async def report(session: web.Committer, project: str, 
version: str, file_path:
             .order_by(sql.sqlmodel.desc(via(sql.Task.completed)))
             .all()
         )
+        augment_tasks = (
+            await data.task(
+                project_name=project,
+                version_name=version,
+                task_type=sql.TaskType.SBOM_AUGMENT,
+                primary_rel_path=file_path,
+            )
+            .order_by(sql.sqlmodel.desc(via(sql.Task.completed)))
+            .all()
+        )
         # Run or running scans for the current revision
         osv_tasks = (
             await data.task(
@@ -90,12 +100,18 @@ async def report(session: web.Committer, project: str, 
version: str, file_path:
     _report_header(block, is_release_candidate, release, task_result)
 
     if not is_release_candidate:
-        _augment_section(block, release, task_result)
+        latest_augment = None
+        last_augmented_bom = None
+        if len(augment_tasks) > 0:
+            latest_augment = augment_tasks[0]
+            augment_results: list[Any] = [t.result for t in augment_tasks]
+            last_augmented_bom = max(
+                [r.bom_version for r in augment_results if r is not None and 
r.bom_version is not None]
+            )
+        _augment_section(block, release, task_result, latest_augment, 
last_augmented_bom)
 
     _conformance_section(block, task_result)
 
-    block.h2["Vulnerabilities"]
-
     if task_result.vulnerabilities is not None:
         vulnerabilities = [
             sbom.models.osv.CdxVulnAdapter.validate_python(json.loads(e)) for 
e in task_result.vulnerabilities
@@ -107,31 +123,57 @@ async def report(session: web.Committer, project: str, 
version: str, file_path:
         block, project, version, file_path, task_result, vulnerabilities, 
osv_tasks, is_release_candidate
     )
 
-    block.h2["Outdated tool"]
-    outdated = None
-    if task_result.outdated:
-        outdated = 
sbom.models.maven.OutdatedAdapter.validate_python(json.loads(task_result.outdated))
-    if outdated:
-        if outdated.kind == "tool":
-            block.p[
-                f"""The CycloneDX Maven Plugin is outdated. The used version is
-                {outdated.used_version} and the available version is
-                {outdated.available_version}."""
-            ]
-        else:
-            block.p[
-                f"""There was a problem with the SBOM detected when trying to
-                determine if the CycloneDX Maven Plugin is outdated:
-                {outdated.kind.upper()}."""
-            ]
-    else:
-        block.p["No outdated tool found."]
+    _outdated_tool_section(block, task_result)
 
     _cyclonedx_cli_errors(block, task_result)
 
     return await template.blank("SBOM report", content=block.collect())
 
 
+def _outdated_tool_section(block: htm.Block, task_result: 
results.SBOMToolScore):
+    block.h2["Outdated tools"]
+    if task_result.outdated:
+        outdated = []
+        if isinstance(task_result.outdated, str):
+            # Older version, only checked one tool
+            outdated = 
[sbom.models.tool.OutdatedAdapter.validate_python(json.loads(task_result.outdated))]
+        elif isinstance(task_result.outdated, list):
+            # Newer version, checked multiple tools
+            outdated = 
[sbom.models.tool.OutdatedAdapter.validate_python(json.loads(o)) for o in 
task_result.outdated]
+        if len(outdated) == 0:
+            block.p["No outdated tools found."]
+        for result in outdated:
+            if result.kind == "tool":
+                if "Apache Trusted Releases" in result.name:
+                    block.p[
+                        f"""The last version of ATR used on this SBOM was
+                            {result.used_version} but ATR is currently version
+                            {result.available_version}."""
+                    ]
+                else:
+                    block.p[
+                        f"""The {result.name} is outdated. The used version is
+                            {result.used_version} and the available version is
+                            {result.available_version}."""
+                    ]
+            else:
+                if result.kind == "missing_metadata" or result.kind == 
"missing_timestamp":
+                    # These both return without checking any further tools as 
they prevent checking
+                    block.p[
+                        f"""There was a problem with the SBOM detected when 
trying to
+                            determine if any tools were outdated:
+                            {result.kind.upper()}."""
+                    ]
+                else:
+                    block.p[
+                        f"""There was a problem with the SBOM detected when 
trying to
+                            determine if the {result.name} is outdated:
+                            {result.kind.upper()}."""
+                    ]
+    else:
+        block.p["No outdated tools found."]
+
+
 def _conformance_section(block: htm.Block, task_result: results.SBOMToolScore) 
-> None:
     warnings = 
[sbom.models.conformance.MissingAdapter.validate_python(json.loads(w)) for w in 
task_result.warnings]
     errors = 
[sbom.models.conformance.MissingAdapter.validate_python(json.loads(e)) for e in 
task_result.errors]
@@ -180,12 +222,28 @@ async def _report_task_results(block: htm.Block, tasks: 
list[sql.Task]):
         return await template.blank("SBOM report", content=block.collect())
 
 
-def _augment_section(block: htm.Block, release: sql.Release, task_result: 
results.SBOMToolScore):
-    # TODO: Show the status if the task to augment the SBOM is still running
-    # And then don't allow it to be augmented again
+def _augment_section(
+    block: htm.Block,
+    release: sql.Release,
+    task_result: results.SBOMToolScore,
+    latest_task: sql.Task | None,
+    last_bom: int | None,
+):
     augments = []
     if task_result.atr_props is not None:
         augments = [t.get("value", "") for t in task_result.atr_props if 
t.get("name", "") == "asf:atr:augment"]
+    if latest_task is not None:
+        result: Any = latest_task.result
+        if latest_task.status == sql.TaskStatus.ACTIVE or latest_task.status 
== sql.TaskStatus.QUEUED:
+            block.p["This SBOM is currently being augmented by ATR."]
+            return
+        if latest_task.status == sql.TaskStatus.FAILED:
+            block.p[f"ATR attempted to augment this SBOM but failed: 
{latest_task.error}"]
+            return
+        if last_bom is not None and result.bom_version == last_bom and 
len(augments) != 0:
+            block.p["This SBOM was augmented by ATR at revision ", 
htm.code[augments[-1]], "."]
+            return
+
     if len(augments) == 0:
         block.p["We can attempt to augment this SBOM with additional data."]
         form.render_block(
@@ -195,6 +253,8 @@ def _augment_section(block: htm.Block, release: 
sql.Release, task_result: result
             empty=True,
         )
     else:
+        # These are edge cases as they cover situations where the BOM says it 
was augmented but we don't have a task
+        # record for it
         if release.latest_revision_number in augments:
             block.p["This SBOM was augmented by ATR."]
         else:
@@ -432,6 +492,8 @@ def _vulnerability_scan_section(
 
     in_progress_task = _vulnerability_scan_find_in_progress_task(osv_tasks, 
task_result.revision_number)
 
+    block.h2["Vulnerabilities"]
+
     scans = []
     if task_result.atr_props is not None:
         scans = [t.get("value", "") for t in task_result.atr_props if 
t.get("name", "") == "asf:atr:osv-scan"]
diff --git a/atr/models/results.py b/atr/models/results.py
index 166a4ed..123324f 100644
--- a/atr/models/results.py
+++ b/atr/models/results.py
@@ -58,6 +58,9 @@ class SBOMOSVScan(schema.Strict):
     project_name: str = schema.description("Project name")
     version_name: str = schema.description("Version name")
     revision_number: str = schema.description("Revision number")
+    bom_version: int | None = schema.Field(
+        default=None, strict=False, description="BOM Version produced with 
scan results"
+    )
     file_path: str = schema.description("Relative path to the scanned SBOM 
file")
     new_file_path: str = schema.Field(default="", strict=False, 
description="Relative path to the updated SBOM file")
     components: list[OSVComponent] = schema.description("Components with 
vulnerabilities")
@@ -103,6 +106,11 @@ class SbomQsReport(schema.Strict):
 class SBOMAugment(schema.Strict):
     kind: Literal["sbom_augment"] = schema.Field(alias="kind")
     path: str = schema.description("The path to the augmented SBOM file")
+    bom_version: int | None = schema.Field(
+        default=None,
+        strict=False,
+        description="BOM Version produced by the augment task, if any 
augmentations were applied",
+    )
 
 
 class SBOMQsScore(schema.Strict):
@@ -119,10 +127,11 @@ class SBOMToolScore(schema.Strict):
     project_name: str = schema.description("Project name")
     version_name: str = schema.description("Version name")
     revision_number: str = schema.description("Revision number")
+    bom_version: int | None = schema.Field(default=None, strict=False, 
description="BOM Version scanned")
     file_path: str = schema.description("Relative path to the scored SBOM 
file")
     warnings: list[str] = schema.description("Warnings from the SBOM tool")
     errors: list[str] = schema.description("Errors from the SBOM tool")
-    outdated: str | None = schema.description("Outdated tool from the SBOM 
tool")
+    outdated: list[str] | str | None = schema.description("Outdated tool(s) 
from the SBOM tool")
     vulnerabilities: list[str] | None = schema.Field(
         default=None, strict=False, description="Vulnerabilities found in the 
SBOM"
     )
diff --git a/atr/sbom/__init__.py b/atr/sbom/__init__.py
index b560995..595ce27 100644
--- a/atr/sbom/__init__.py
+++ b/atr/sbom/__init__.py
@@ -15,7 +15,7 @@
 # specific language governing permissions and limitations
 # under the License.
 
-from . import cli, conformance, constants, cyclonedx, licenses, maven, models, 
osv, sbomqs, spdx, utilities
+from . import cli, conformance, constants, cyclonedx, licenses, maven, models, 
osv, sbomqs, spdx, tool, utilities
 
 __all__ = [
     "cli",
@@ -28,5 +28,6 @@ __all__ = [
     "osv",
     "sbomqs",
     "spdx",
+    "tool",
     "utilities",
 ]
diff --git a/atr/sbom/cli.py b/atr/sbom/cli.py
index edc93fd..74647a4 100644
--- a/atr/sbom/cli.py
+++ b/atr/sbom/cli.py
@@ -25,8 +25,8 @@ from . import models, osv
 from .conformance import ntia_2021_issues
 from .cyclonedx import validate_cli, validate_py
 from .licenses import check
-from .maven import plugin_outdated_version
 from .sbomqs import total_score
+from .tool import plugin_outdated_version
 from .utilities import bundle_to_ntia_patch, bundle_to_vuln_patch, 
patch_to_data, path_to_bundle
 
 
diff --git a/atr/sbom/maven.py b/atr/sbom/maven.py
index a443414..5ff9bc5 100644
--- a/atr/sbom/maven.py
+++ b/atr/sbom/maven.py
@@ -17,14 +17,13 @@
 
 from __future__ import annotations
 
-import datetime
 import pathlib
 import tempfile
 from typing import Any, Final
 
 import yyjson
 
-from . import constants, models
+from . import constants
 
 _CACHE_PATH: Final[pathlib.Path] = pathlib.Path(tempfile.gettempdir()) / 
"sbomtool-cache.json"
 
@@ -49,57 +48,6 @@ def cache_write(cache: dict[str, Any]) -> None:
         pass
 
 
-def plugin_outdated_version(bom_value: models.bom.Bom) -> 
models.maven.Outdated | None:
-    if bom_value.metadata is None:
-        return models.maven.OutdatedMissingMetadata()
-    timestamp = bom_value.metadata.timestamp
-    if timestamp is None:
-        # This quite often isn't available
-        # We could use the file mtime, but that's extremely heuristic
-        # return OutdatedMissingTimestamp()
-        timestamp = 
datetime.datetime.now(datetime.UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
-    tools = []
-    tools_value = bom_value.metadata.tools
-    if isinstance(tools_value, list):
-        tools = tools_value
-    elif tools_value:
-        tools = tools_value.components or []
-    for tool in tools:
-        names_or_descriptions = {
-            "cyclonedx maven plugin",
-            "cyclonedx-maven-plugin",
-        }
-        name_or_description = (tool.name or tool.description or "").lower()
-        if name_or_description not in names_or_descriptions:
-            continue
-        if tool.version is None:
-            return 
models.maven.OutdatedMissingVersion(name=name_or_description)
-        available_version = plugin_outdated_version_core(timestamp, 
tool.version)
-        if available_version is not None:
-            return models.maven.OutdatedTool(
-                name=name_or_description,
-                used_version=tool.version,
-                available_version=available_version,
-            )
-    return None
-
-
-def plugin_outdated_version_core(isotime: str, version: str) -> str | None:
-    expected_version = version_as_of(isotime)
-    if expected_version is None:
-        return None
-    if version == expected_version:
-        return None
-    expected_version_comparable = version_parse(expected_version)
-    version_comparable = version_parse(version)
-    # If the version used is less than the version available
-    if version_comparable < expected_version_comparable:
-        # Then note the version available
-        return expected_version
-    # Otherwise, the user is using the latest version
-    return None
-
-
 def version_as_of(isotime: str) -> str | None:
     # Given these mappings:
     # {
@@ -115,8 +63,3 @@ def version_as_of(isotime: str) -> str | None:
         if isotime >= date:
             return version
     return None
-
-
-def version_parse(version: str) -> tuple[int, int, int]:
-    parts = version.split(".")
-    return int(parts[0]), int(parts[1]), int(parts[2])
diff --git a/atr/sbom/models/__init__.py b/atr/sbom/models/__init__.py
index fd44fb8..15cf734 100644
--- a/atr/sbom/models/__init__.py
+++ b/atr/sbom/models/__init__.py
@@ -17,16 +17,6 @@
 
 from __future__ import annotations
 
-from . import base, bom, bundle, conformance, licenses, maven, osv, patch, 
sbomqs
+from . import base, bom, bundle, conformance, licenses, osv, patch, sbomqs, 
tool
 
-__all__ = [
-    "base",
-    "bom",
-    "bundle",
-    "conformance",
-    "licenses",
-    "maven",
-    "osv",
-    "patch",
-    "sbomqs",
-]
+__all__ = ["base", "bom", "bundle", "conformance", "licenses", "osv", "patch", 
"sbomqs", "tool"]
diff --git a/atr/sbom/models/bom.py b/atr/sbom/models/bom.py
index 3bc65f9..e02dff2 100644
--- a/atr/sbom/models/bom.py
+++ b/atr/sbom/models/bom.py
@@ -59,6 +59,15 @@ class ToolComponent(Lax):
     name: str | None = None
     version: str | None = None
     description: str | None = None
+    supplier: Supplier | None = None
+
+
+class ServiceComponent(Lax):
+    name: str | None = None
+    version: str | None = None
+    description: str | None = None
+    supplier: Supplier | None = None
+    authenticated: bool | None = None
 
 
 class Tool(Lax):
@@ -69,6 +78,7 @@ class Tool(Lax):
 
 class Tools(Lax):
     components: list[ToolComponent] | None = None
+    services: list[ServiceComponent] | None = None
 
 
 class Metadata(Lax):
diff --git a/atr/sbom/models/maven.py b/atr/sbom/models/tool.py
similarity index 86%
rename from atr/sbom/models/maven.py
rename to atr/sbom/models/tool.py
index 8f87551..1e23fa9 100644
--- a/atr/sbom/models/maven.py
+++ b/atr/sbom/models/tool.py
@@ -17,12 +17,15 @@
 
 from __future__ import annotations
 
-from typing import Annotated, Literal
+from typing import TYPE_CHECKING, Annotated, Literal, NamedTuple
 
 import pydantic
 
 from .base import Strict
 
+if TYPE_CHECKING:
+    from collections.abc import Callable
+
 
 class OutdatedTool(Strict):
     kind: Literal["tool"] = "tool"
@@ -44,6 +47,12 @@ class OutdatedMissingVersion(Strict):
     name: str
 
 
+class Tool(NamedTuple):
+    key: str
+    friendly_name: str
+    version_function: Callable[[str], str | None]
+
+
 type Outdated = Annotated[
     OutdatedTool | OutdatedMissingMetadata | OutdatedMissingTimestamp | 
OutdatedMissingVersion,
     pydantic.Field(discriminator="kind"),
diff --git a/atr/sbom/osv.py b/atr/sbom/osv.py
index 7008ef9..01609ba 100644
--- a/atr/sbom/osv.py
+++ b/atr/sbom/osv.py
@@ -108,7 +108,6 @@ def vulns_from_bundle(bundle: models.bundle.Bundle) -> 
list[models.osv.CdxVulner
 
 
 async def vuln_patch(
-    session: aiohttp.ClientSession,
     doc: yyjson.Document,
     components: list[models.osv.ComponentVulnerabilities],
 ) -> models.patch.Patch:
diff --git a/atr/sbom/tool.py b/atr/sbom/tool.py
new file mode 100644
index 0000000..febf733
--- /dev/null
+++ b/atr/sbom/tool.py
@@ -0,0 +1,104 @@
+# 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.
+
+from __future__ import annotations
+
+import datetime
+from typing import TYPE_CHECKING, Any, Final
+
+import semver
+
+from . import maven, models, utilities
+
+if TYPE_CHECKING:
+    from collections.abc import Callable
+
+
+_KNOWN_TOOLS: Final[dict[str, models.tool.Tool]] = {
+    # name in file: ( canonical name, friendly name, version callable )
+    "cyclonedx-maven-plugin": models.tool.Tool("cyclonedx-maven-plugin", 
"CycloneDX Maven Plugin", maven.version_as_of),
+    "cyclonedx maven plugin": models.tool.Tool("cyclonedx-maven-plugin", 
"CycloneDX Maven Plugin", maven.version_as_of),
+    "apache trusted releases": models.tool.Tool(
+        "apache trusted releases", "Apache Trusted Releases platform", lambda 
_: utilities.get_atr_version()
+    ),
+}
+
+
+def plugin_outdated_version(bom_value: models.bom.Bom) -> 
list[models.tool.Outdated] | None:
+    if bom_value.metadata is None:
+        return [models.tool.OutdatedMissingMetadata()]
+    timestamp = bom_value.metadata.timestamp
+    if timestamp is None:
+        # This quite often isn't available
+        # We could use the file mtime, but that's extremely heuristic
+        # return OutdatedMissingTimestamp()
+        timestamp = 
datetime.datetime.now(datetime.UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
+    tools: list[Any] = []
+    tools_value = bom_value.metadata.tools
+    if isinstance(tools_value, list):
+        tools = tools_value
+    elif tools_value:
+        tools = tools_value.components or []
+        services = tools_value.services or []
+        tools.extend(services)
+    errors = []
+    for tool in tools:
+        name_or_description = (tool.name or tool.description or "").lower()
+        if name_or_description not in _KNOWN_TOOLS:
+            continue
+        if tool.version is None:
+            
errors.append(models.tool.OutdatedMissingVersion(name=name_or_description))
+            continue
+        tool_data = _KNOWN_TOOLS[name_or_description]
+        available_version = outdated_version_core(timestamp, tool.version, 
tool_data.version_function)
+        if available_version is not None:
+            errors.append(
+                models.tool.OutdatedTool(
+                    name=tool_data.friendly_name,
+                    used_version=tool.version,
+                    available_version=str(available_version),
+                )
+            )
+    return errors
+
+
+def outdated_version_core(
+    isotime: str, version: str, version_as_of: Callable[[str], str | None]
+) -> semver.VersionInfo | None:
+    expected_version = version_as_of(isotime)
+    if expected_version is None:
+        return None
+    if version == expected_version:
+        return None
+    expected_version_comparable = version_parse(expected_version)
+    version_comparable = version_parse(version)
+    if expected_version_comparable is None or version_comparable is None:
+        # Couldn't parse the version
+        return None
+    # If the version used is less than the version available
+    if version_comparable < expected_version_comparable:
+        # Then note the version available
+        return expected_version_comparable
+    # Otherwise, the user is using the latest version
+    return None
+
+
+def version_parse(version_str: str) -> semver.VersionInfo | None:
+    try:
+        return semver.VersionInfo.parse(version_str.lstrip("v"))
+    except ValueError:
+        return None
diff --git a/atr/sbom/utilities.py b/atr/sbom/utilities.py
index 36a2cf5..fa91930 100644
--- a/atr/sbom/utilities.py
+++ b/atr/sbom/utilities.py
@@ -30,11 +30,44 @@ if TYPE_CHECKING:
 import aiohttp
 import yyjson
 
-from . import models
+from . import constants, models
+
+
+def get_atr_version():
+    try:
+        from atr import metadata
+
+        return metadata.version
+    except ImportError:
+        return "cli"
+
+
+_ATR_VERSION = get_atr_version()
 
 _SCORING_METHODS_OSV = {"CVSS_V2": "CVSSv2", "CVSS_V3": "CVSSv3", "CVSS_V4": 
"CVSSv4"}
 _SCORING_METHODS_CDX = {"CVSSv2": "CVSS_V2", "CVSSv3": "CVSS_V3", "CVSSv4": 
"CVSS_V4", "other": "Other"}
 _CDX_SEVERITIES = ["critical", "high", "medium", "low", "info", "none", 
"unknown"]
+_TOOL_METADATA = {
+    "bom-ref": "tool:asf:atr",
+    "provider": {
+        "name": constants.conformance.THE_APACHE_SOFTWARE_FOUNDATION,
+        "url": ["https://apache.org/";],
+    },
+    "name": "Apache Trusted Releases",
+    "authenticated": True,
+    "version": _ATR_VERSION,
+}
+
+
+def apply_patch(
+    reason: str, revision: str, bundle: models.bundle.Bundle, patch_ops: 
models.patch.Patch
+) -> tuple[int, yyjson.Document]:
+    """Take a list of patch operations and apply them to the bundle. Returns 
the patched document, with the
+    task recorded in `properties` and the SBOM version number incremented as 
per the CDX spec."""
+    _record_task(reason, revision, bundle.doc, patch_ops)
+    new_version = _increment_version(bundle.doc, patch_ops)
+    patch_data = patch_to_data(patch_ops)
+    return new_version, bundle.doc.patch(yyjson.Document(patch_data))
 
 
 async def bundle_to_ntia_patch(bundle_value: models.bundle.Bundle) -> 
models.patch.Patch:
@@ -51,9 +84,7 @@ async def bundle_to_vuln_patch(
 ) -> models.patch.Patch:
     from .osv import vuln_patch
 
-    # TODO: May not need session (copied from ntia patch)
-    async with aiohttp.ClientSession() as session:
-        patch_ops = await vuln_patch(session, bundle_value.doc, 
vulnerabilities)
+    patch_ops = await vuln_patch(bundle_value.doc, vulnerabilities)
     return patch_ops
 
 
@@ -67,11 +98,14 @@ def get_pointer(doc: yyjson.Document, path: str) -> Any | 
None:
         raise
 
 
-def get_atr_props_from_bundle(bundle_value: models.bundle.Bundle) -> 
list[dict[str, str]]:
+def get_props_from_bundle(bundle_value: models.bundle.Bundle) -> tuple[int, 
list[dict[str, str]]]:
+    version: int | None = get_pointer(bundle_value.doc, "/version")
+    if version is None:
+        version = 0
     properties: list[dict[str, str]] | None = get_pointer(bundle_value.doc, 
"/properties")
     if properties is None:
-        return []
-    return [p for p in properties if "asf:atr:" in p.get("name", "")]
+        return version, []
+    return version, [p for p in properties if "asf:atr:" in p.get("name", "")]
 
 
 def patch_to_data(patch_ops: models.patch.Patch) -> list[dict[str, Any]]:
@@ -84,17 +118,6 @@ def path_to_bundle(path: pathlib.Path) -> 
models.bundle.Bundle:
     return models.bundle.Bundle(doc=yyjson.Document(text), bom=bom, path=path, 
text=text)
 
 
-def record_task(task: str, revision: str, doc: yyjson.Document, patch_ops: 
models.patch.Patch) -> models.patch.Patch:
-    properties: list[dict[str, str]] | None = get_pointer(doc, "/properties")
-    operation = {"name": f"asf:atr:{task}", "value": revision}
-    if properties is None:
-        patch_ops.append(models.patch.AddOp(op="add", path="/properties", 
value=[operation]))
-    else:
-        properties.append(operation)
-        patch_ops.append(models.patch.ReplaceOp(op="replace", 
path="/properties", value=properties))
-    return patch_ops
-
-
 def osv_severity_to_cdx(severity: list[dict[str, Any]] | None, textual: str) 
-> list[dict[str, str | float]] | None:
     if severity is not None:
         return [
@@ -155,6 +178,18 @@ def _extract_cdx_score(type: str, score_str: str) -> 
dict[str, str | float]:
             return {"severity": score_str}
 
 
+def _increment_version(doc: yyjson.Document, patch_ops: models.patch.Patch) -> 
int:
+    version: int | None = get_pointer(doc, "/version")
+    if version is not None:
+        version = version + 1
+        patch_ops.append(models.patch.ReplaceOp(op="replace", path="/version", 
value=version))
+    else:
+        # This shouldn't happen, but we can handle it just in case
+        version = 1
+        patch_ops.append(models.patch.AddOp(op="add", path="/version", 
value=version))
+    return version
+
+
 def _map_severity(severity: str) -> str:
     sev = severity.lower()
     if sev in _CDX_SEVERITIES:
@@ -164,3 +199,35 @@ def _map_severity(severity: str) -> str:
         if sev == "moderate":
             return "medium"
     return "unknown"
+
+
+def _record_task(task: str, revision: str, doc: yyjson.Document, patch_ops: 
models.patch.Patch) -> models.patch.Patch:
+    properties: list[dict[str, str]] | None = get_pointer(doc, "/properties")
+    tools: dict[str, Any] | None = get_pointer(doc, "/metadata/tools")
+    services: list[dict[str, Any]] | None = get_pointer(doc, 
"/metadata/tools/services")
+    operation = {"name": f"asf:atr:{task}", "value": revision}
+    if properties is None:
+        patch_ops.append(models.patch.AddOp(op="add", path="/properties", 
value=[operation]))
+    else:
+        properties.append(operation)
+        patch_ops.append(models.patch.ReplaceOp(op="replace", 
path="/properties", value=properties))
+    if tools is None:
+        tools = {}
+        patch_ops.append(models.patch.AddOp(op="add", path="/metadata/tools", 
value=tools))
+    if services is None:
+        services = []
+        patch_ops.append(models.patch.AddOp(op="add", 
path="/metadata/tools/services", value=services))
+    try:
+        tool_index = [s.get("bom-ref", "") for s in 
services].index("tool:asf:atr")
+    except ValueError:
+        tool_index = -1
+    if tool_index == -1:
+        patch_ops.append(
+            models.patch.AddOp(op="add", 
path=f"/metadata/tools/services/{len(services)}", value=_TOOL_METADATA)
+        )
+    else:
+        # If we ever add more metadata to this, such as task-specific 
properties under the tool, this changes
+        patch_ops.append(
+            models.patch.ReplaceOp(op="replace", 
path=f"/metadata/tools/services/{tool_index}", value=_TOOL_METADATA)
+        )
+    return patch_ops
diff --git a/atr/tasks/sbom.py b/atr/tasks/sbom.py
index d07dd0d..506939b 100644
--- a/atr/tasks/sbom.py
+++ b/atr/tasks/sbom.py
@@ -23,7 +23,6 @@ from typing import Any, Final
 
 import aiofiles
 import aiofiles.os
-import yyjson
 
 import atr.archives as archives
 import atr.config as config
@@ -87,10 +86,9 @@ async def augment(args: FileArgs) -> results.Results | None:
     bundle = sbom.utilities.path_to_bundle(pathlib.Path(full_path))
     patch_ops = await sbom.utilities.bundle_to_ntia_patch(bundle)
     new_full_path: str | None = None
+    new_version = None
     if patch_ops:
-        sbom.utilities.record_task("augment", args.revision_number, 
bundle.doc, patch_ops)
-        patch_data = sbom.utilities.patch_to_data(patch_ops)
-        merged = bundle.doc.patch(yyjson.Document(patch_data))
+        new_version, merged = sbom.utilities.apply_patch("augment", 
args.revision_number, bundle, patch_ops)
         description = "SBOM augmentation through web interface"
         async with storage.write(args.asf_uid) as write:
             wacp = await 
write.as_project_committee_participant(args.project_name)
@@ -110,6 +108,7 @@ async def augment(args: FileArgs) -> results.Results | None:
     return results.SBOMAugment(
         kind="sbom_augment",
         path=(new_full_path if new_full_path is not None else full_path),
+        bom_version=new_version,
     )
 
 
@@ -145,31 +144,29 @@ async def osv_scan(args: FileArgs) -> results.Results | 
None:
     components = [results.OSVComponent(purl=v.ref, 
vulnerabilities=v.vulnerabilities) for v in vulnerabilities]
 
     new_full_path: str | None = None
-    if patch_ops:
-        sbom.utilities.record_task("osv-scan", args.revision_number, 
bundle.doc, patch_ops)
-        patch_data = sbom.utilities.patch_to_data(patch_ops)
-        merged = bundle.doc.patch(yyjson.Document(patch_data))
-        description = "SBOM vulnerability scan through web interface"
-        async with storage.write(args.asf_uid) as write:
-            wacp = await 
write.as_project_committee_participant(args.project_name)
-            async with wacp.revision.create_and_manage(
-                args.project_name, args.version_name, args.asf_uid or 
"unknown", description=description
-            ) as creating:
-                new_full_path = os.path.join(str(creating.interim_path), 
args.file_path)
-                # Write to the new revision
-                log.info(f"Writing updated SBOM to {new_full_path}")
-                await aiofiles.os.remove(new_full_path)
-                async with aiofiles.open(new_full_path, "w", encoding="utf-8") 
as f:
-                    await f.write(merged.dumps())
-
-            if creating.new is None:
-                raise RuntimeError("Internal error: New revision not found")
+    new_version, merged = sbom.utilities.apply_patch("osv-scan", 
args.revision_number, bundle, patch_ops)
+    description = "SBOM vulnerability scan through web interface"
+    async with storage.write(args.asf_uid) as write:
+        wacp = await write.as_project_committee_participant(args.project_name)
+        async with wacp.revision.create_and_manage(
+            args.project_name, args.version_name, args.asf_uid or "unknown", 
description=description
+        ) as creating:
+            new_full_path = os.path.join(str(creating.interim_path), 
args.file_path)
+            # Write to the new revision
+            log.info(f"Writing updated SBOM to {new_full_path}")
+            await aiofiles.os.remove(new_full_path)
+            async with aiofiles.open(new_full_path, "w", encoding="utf-8") as 
f:
+                await f.write(merged.dumps())
+
+        if creating.new is None:
+            raise RuntimeError("Internal error: New revision not found")
 
     return results.SBOMOSVScan(
         kind="sbom_osv_scan",
         project_name=args.project_name,
         version_name=args.version_name,
         revision_number=args.revision_number,
+        bom_version=new_version,
         file_path=full_path,
         new_file_path=new_full_path or full_path,
         components=components,
@@ -221,9 +218,10 @@ async def score_tool(args: FileArgs) -> results.Results | 
None:
     if not (full_path.endswith(".cdx.json") and os.path.isfile(full_path)):
         raise SBOMScoringError("SBOM file does not exist", {"file_path": 
args.file_path})
     bundle = sbom.utilities.path_to_bundle(pathlib.Path(full_path))
-    properties = sbom.utilities.get_atr_props_from_bundle(bundle)
+    version, properties = sbom.utilities.get_props_from_bundle(bundle)
     warnings, errors = sbom.conformance.ntia_2021_issues(bundle.bom)
-    outdated = sbom.maven.plugin_outdated_version(bundle.bom)
+    # TODO: Could update the ATR version with a constant showing last change 
to the augment/scan tools
+    outdated = sbom.tool.plugin_outdated_version(bundle.bom)
     vulnerabilities = sbom.osv.vulns_from_bundle(bundle)
     cli_errors = sbom.cyclonedx.validate_cli(bundle)
     return results.SBOMToolScore(
@@ -231,10 +229,11 @@ async def score_tool(args: FileArgs) -> results.Results | 
None:
         project_name=args.project_name,
         version_name=args.version_name,
         revision_number=args.revision_number,
+        bom_version=version,
         file_path=args.file_path,
         warnings=[w.model_dump_json() for w in warnings],
         errors=[e.model_dump_json() for e in errors],
-        outdated=outdated.model_dump_json() if outdated else None,
+        outdated=[o.model_dump_json() for o in outdated] if outdated else None,
         vulnerabilities=[v.model_dump_json() for v in vulnerabilities],
         atr_props=properties,
         cli_errors=cli_errors,
diff --git a/pyproject.toml b/pyproject.toml
index 6c41378..a1263df 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -43,6 +43,7 @@ dependencies = [
   "quart-schema[pydantic]~=0.21",
   "quart-wtforms~=1.0.3",
   "rich~=14.0.0",
+  "semver>=3.0.4",
   "sqlmodel~=0.0.24",
   "standard-imghdr>=3.13.0",
   "yyjson>=4.0.6",
diff --git a/uv.lock b/uv.lock
index be40bd1..8b73407 100644
--- a/uv.lock
+++ b/uv.lock
@@ -3,7 +3,7 @@ revision = 3
 requires-python = "==3.13.*"
 
 [options]
-exclude-newer = "2025-12-17T17:16:16Z"
+exclude-newer = "2025-12-19T17:04:05Z"
 
 [[package]]
 name = "aiofiles"
@@ -1661,28 +1661,37 @@ wheels = [
 
 [[package]]
 name = "ruff"
-version = "0.14.9"
-source = { registry = "https://pypi.org/simple"; }
-sdist = { url = 
"https://files.pythonhosted.org/packages/f6/1b/ab712a9d5044435be8e9a2beb17cbfa4c241aa9b5e4413febac2a8b79ef2/ruff-0.14.9.tar.gz";,
 hash = 
"sha256:35f85b25dd586381c0cc053f48826109384c81c00ad7ef1bd977bfcc28119d5b", size 
= 5809165, upload-time = "2025-12-11T21:39:47.381Z" }
-wheels = [
-    { url = 
"https://files.pythonhosted.org/packages/b8/1c/d1b1bba22cffec02351c78ab9ed4f7d7391876e12720298448b29b7229c1/ruff-0.14.9-py3-none-linux_armv6l.whl";,
 hash = 
"sha256:f1ec5de1ce150ca6e43691f4a9ef5c04574ad9ca35c8b3b0e18877314aba7e75", size 
= 13576541, upload-time = "2025-12-11T21:39:14.806Z" },
-    { url = 
"https://files.pythonhosted.org/packages/94/ab/ffe580e6ea1fca67f6337b0af59fc7e683344a43642d2d55d251ff83ceae/ruff-0.14.9-py3-none-macosx_10_12_x86_64.whl";,
 hash = 
"sha256:ed9d7417a299fc6030b4f26333bf1117ed82a61ea91238558c0268c14e00d0c2", size 
= 13779363, upload-time = "2025-12-11T21:39:20.29Z" },
-    { url = 
"https://files.pythonhosted.org/packages/7d/f8/2be49047f929d6965401855461e697ab185e1a6a683d914c5c19c7962d9e/ruff-0.14.9-py3-none-macosx_11_0_arm64.whl";,
 hash = 
"sha256:d5dc3473c3f0e4a1008d0ef1d75cee24a48e254c8bed3a7afdd2b4392657ed2c", size 
= 12925292, upload-time = "2025-12-11T21:39:38.757Z" },
-    { url = 
"https://files.pythonhosted.org/packages/9e/e9/08840ff5127916bb989c86f18924fd568938b06f58b60e206176f327c0fe/ruff-0.14.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl";,
 hash = 
"sha256:84bf7c698fc8f3cb8278830fb6b5a47f9bcc1ed8cb4f689b9dd02698fa840697", size 
= 13362894, upload-time = "2025-12-11T21:39:02.524Z" },
-    { url = 
"https://files.pythonhosted.org/packages/31/1c/5b4e8e7750613ef43390bb58658eaf1d862c0cc3352d139cd718a2cea164/ruff-0.14.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl";,
 hash = 
"sha256:aa733093d1f9d88a5d98988d8834ef5d6f9828d03743bf5e338bf980a19fce27", size 
= 13311482, upload-time = "2025-12-11T21:39:17.51Z" },
-    { url = 
"https://files.pythonhosted.org/packages/5b/3a/459dce7a8cb35ba1ea3e9c88f19077667a7977234f3b5ab197fad240b404/ruff-0.14.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl";,
 hash = 
"sha256:6a1cfb04eda979b20c8c19550c8b5f498df64ff8da151283311ce3199e8b3648", size 
= 14016100, upload-time = "2025-12-11T21:39:41.948Z" },
-    { url = 
"https://files.pythonhosted.org/packages/a6/31/f064f4ec32524f9956a0890fc6a944e5cf06c63c554e39957d208c0ffc45/ruff-0.14.9-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl";,
 hash = 
"sha256:1e5cb521e5ccf0008bd74d5595a4580313844a42b9103b7388eca5a12c970743", size 
= 15477729, upload-time = "2025-12-11T21:39:23.279Z" },
-    { url = 
"https://files.pythonhosted.org/packages/7a/6d/f364252aad36ccd443494bc5f02e41bf677f964b58902a17c0b16c53d890/ruff-0.14.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl";,
 hash = 
"sha256:cd429a8926be6bba4befa8cdcf3f4dd2591c413ea5066b1e99155ed245ae42bb", size 
= 15122386, upload-time = "2025-12-11T21:39:33.125Z" },
-    { url = 
"https://files.pythonhosted.org/packages/20/02/e848787912d16209aba2799a4d5a1775660b6a3d0ab3944a4ccc13e64a02/ruff-0.14.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl";,
 hash = 
"sha256:ab208c1b7a492e37caeaf290b1378148f75e13c2225af5d44628b95fd7834273", size 
= 14497124, upload-time = "2025-12-11T21:38:59.33Z" },
-    { url = 
"https://files.pythonhosted.org/packages/f3/51/0489a6a5595b7760b5dbac0dd82852b510326e7d88d51dbffcd2e07e3ff3/ruff-0.14.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl";,
 hash = 
"sha256:72034534e5b11e8a593f517b2f2f2b273eb68a30978c6a2d40473ad0aaa4cb4a", size 
= 14195343, upload-time = "2025-12-11T21:39:44.866Z" },
-    { url = 
"https://files.pythonhosted.org/packages/f6/53/3bb8d2fa73e4c2f80acc65213ee0830fa0c49c6479313f7a68a00f39e208/ruff-0.14.9-py3-none-manylinux_2_31_riscv64.whl";,
 hash = 
"sha256:712ff04f44663f1b90a1195f51525836e3413c8a773574a7b7775554269c30ed", size 
= 14346425, upload-time = "2025-12-11T21:39:05.927Z" },
-    { url = 
"https://files.pythonhosted.org/packages/ad/04/bdb1d0ab876372da3e983896481760867fc84f969c5c09d428e8f01b557f/ruff-0.14.9-py3-none-musllinux_1_2_aarch64.whl";,
 hash = 
"sha256:a111fee1db6f1d5d5810245295527cda1d367c5aa8f42e0fca9a78ede9b4498b", size 
= 13258768, upload-time = "2025-12-11T21:39:08.691Z" },
-    { url = 
"https://files.pythonhosted.org/packages/40/d9/8bf8e1e41a311afd2abc8ad12be1b6c6c8b925506d9069b67bb5e9a04af3/ruff-0.14.9-py3-none-musllinux_1_2_armv7l.whl";,
 hash = 
"sha256:8769efc71558fecc25eb295ddec7d1030d41a51e9dcf127cbd63ec517f22d567", size 
= 13326939, upload-time = "2025-12-11T21:39:53.842Z" },
-    { url = 
"https://files.pythonhosted.org/packages/f4/56/a213fa9edb6dd849f1cfbc236206ead10913693c72a67fb7ddc1833bf95d/ruff-0.14.9-py3-none-musllinux_1_2_i686.whl";,
 hash = 
"sha256:347e3bf16197e8a2de17940cd75fd6491e25c0aa7edf7d61aa03f146a1aa885a", size 
= 13578888, upload-time = "2025-12-11T21:39:35.988Z" },
-    { url = 
"https://files.pythonhosted.org/packages/33/09/6a4a67ffa4abae6bf44c972a4521337ffce9cbc7808faadede754ef7a79c/ruff-0.14.9-py3-none-musllinux_1_2_x86_64.whl";,
 hash = 
"sha256:7715d14e5bccf5b660f54516558aa94781d3eb0838f8e706fb60e3ff6eff03a8", size 
= 14314473, upload-time = "2025-12-11T21:39:50.78Z" },
-    { url = 
"https://files.pythonhosted.org/packages/12/0d/15cc82da5d83f27a3c6b04f3a232d61bc8c50d38a6cd8da79228e5f8b8d6/ruff-0.14.9-py3-none-win32.whl";,
 hash = 
"sha256:df0937f30aaabe83da172adaf8937003ff28172f59ca9f17883b4213783df197", size 
= 13202651, upload-time = "2025-12-11T21:39:26.628Z" },
-    { url = 
"https://files.pythonhosted.org/packages/32/f7/c78b060388eefe0304d9d42e68fab8cffd049128ec466456cef9b8d4f06f/ruff-0.14.9-py3-none-win_amd64.whl";,
 hash = 
"sha256:c0b53a10e61df15a42ed711ec0bda0c582039cf6c754c49c020084c55b5b0bc2", size 
= 14702079, upload-time = "2025-12-11T21:39:11.954Z" },
-    { url = 
"https://files.pythonhosted.org/packages/26/09/7a9520315decd2334afa65ed258fed438f070e31f05a2e43dd480a5e5911/ruff-0.14.9-py3-none-win_arm64.whl";,
 hash = 
"sha256:8e821c366517a074046d92f0e9213ed1c13dbc5b37a7fc20b07f79b64d62cc84", size 
= 13744730, upload-time = "2025-12-11T21:39:29.659Z" },
+version = "0.14.10"
+source = { registry = "https://pypi.org/simple"; }
+sdist = { url = 
"https://files.pythonhosted.org/packages/57/08/52232a877978dd8f9cf2aeddce3e611b40a63287dfca29b6b8da791f5e8d/ruff-0.14.10.tar.gz";,
 hash = 
"sha256:9a2e830f075d1a42cd28420d7809ace390832a490ed0966fe373ba288e77aaf4", size 
= 5859763, upload-time = "2025-12-18T19:28:57.98Z" }
+wheels = [
+    { url = 
"https://files.pythonhosted.org/packages/60/01/933704d69f3f05ee16ef11406b78881733c186fe14b6a46b05cfcaf6d3b2/ruff-0.14.10-py3-none-linux_armv6l.whl";,
 hash = 
"sha256:7a3ce585f2ade3e1f29ec1b92df13e3da262178df8c8bdf876f48fa0e8316c49", size 
= 13527080, upload-time = "2025-12-18T19:29:25.642Z" },
+    { url = 
"https://files.pythonhosted.org/packages/df/58/a0349197a7dfa603ffb7f5b0470391efa79ddc327c1e29c4851e85b09cc5/ruff-0.14.10-py3-none-macosx_10_12_x86_64.whl";,
 hash = 
"sha256:674f9be9372907f7257c51f1d4fc902cb7cf014b9980152b802794317941f08f", size 
= 13797320, upload-time = "2025-12-18T19:29:02.571Z" },
+    { url = 
"https://files.pythonhosted.org/packages/7b/82/36be59f00a6082e38c23536df4e71cdbc6af8d7c707eade97fcad5c98235/ruff-0.14.10-py3-none-macosx_11_0_arm64.whl";,
 hash = 
"sha256:d85713d522348837ef9df8efca33ccb8bd6fcfc86a2cde3ccb4bc9d28a18003d", size 
= 12918434, upload-time = "2025-12-18T19:28:51.202Z" },
+    { url = 
"https://files.pythonhosted.org/packages/a6/00/45c62a7f7e34da92a25804f813ebe05c88aa9e0c25e5cb5a7d23dd7450e3/ruff-0.14.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl";,
 hash = 
"sha256:6987ebe0501ae4f4308d7d24e2d0fe3d7a98430f5adfd0f1fead050a740a3a77", size 
= 13371961, upload-time = "2025-12-18T19:29:04.991Z" },
+    { url = 
"https://files.pythonhosted.org/packages/40/31/a5906d60f0405f7e57045a70f2d57084a93ca7425f22e1d66904769d1628/ruff-0.14.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl";,
 hash = 
"sha256:16a01dfb7b9e4eee556fbfd5392806b1b8550c9b4a9f6acd3dbe6812b193c70a", size 
= 13275629, upload-time = "2025-12-18T19:29:21.381Z" },
+    { url = 
"https://files.pythonhosted.org/packages/3e/60/61c0087df21894cf9d928dc04bcd4fb10e8b2e8dca7b1a276ba2155b2002/ruff-0.14.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl";,
 hash = 
"sha256:7165d31a925b7a294465fa81be8c12a0e9b60fb02bf177e79067c867e71f8b1f", size 
= 14029234, upload-time = "2025-12-18T19:29:00.132Z" },
+    { url = 
"https://files.pythonhosted.org/packages/44/84/77d911bee3b92348b6e5dab5a0c898d87084ea03ac5dc708f46d88407def/ruff-0.14.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl";,
 hash = 
"sha256:c561695675b972effb0c0a45db233f2c816ff3da8dcfbe7dfc7eed625f218935", size 
= 15449890, upload-time = "2025-12-18T19:28:53.573Z" },
+    { url = 
"https://files.pythonhosted.org/packages/e9/36/480206eaefa24a7ec321582dda580443a8f0671fdbf6b1c80e9c3e93a16a/ruff-0.14.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl";,
 hash = 
"sha256:4bb98fcbbc61725968893682fd4df8966a34611239c9fd07a1f6a07e7103d08e", size 
= 15123172, upload-time = "2025-12-18T19:29:23.453Z" },
+    { url = 
"https://files.pythonhosted.org/packages/5c/38/68e414156015ba80cef5473d57919d27dfb62ec804b96180bafdeaf0e090/ruff-0.14.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl";,
 hash = 
"sha256:f24b47993a9d8cb858429e97bdf8544c78029f09b520af615c1d261bf827001d", size 
= 14460260, upload-time = "2025-12-18T19:29:27.808Z" },
+    { url = 
"https://files.pythonhosted.org/packages/b3/19/9e050c0dca8aba824d67cc0db69fb459c28d8cd3f6855b1405b3f29cc91d/ruff-0.14.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl";,
 hash = 
"sha256:59aabd2e2c4fd614d2862e7939c34a532c04f1084476d6833dddef4afab87e9f", size 
= 14229978, upload-time = "2025-12-18T19:29:11.32Z" },
+    { url = 
"https://files.pythonhosted.org/packages/51/eb/e8dd1dd6e05b9e695aa9dd420f4577debdd0f87a5ff2fedda33c09e9be8c/ruff-0.14.10-py3-none-manylinux_2_31_riscv64.whl";,
 hash = 
"sha256:213db2b2e44be8625002dbea33bb9c60c66ea2c07c084a00d55732689d697a7f", size 
= 14338036, upload-time = "2025-12-18T19:29:09.184Z" },
+    { url = 
"https://files.pythonhosted.org/packages/6a/12/f3e3a505db7c19303b70af370d137795fcfec136d670d5de5391e295c134/ruff-0.14.10-py3-none-musllinux_1_2_aarch64.whl";,
 hash = 
"sha256:b914c40ab64865a17a9a5b67911d14df72346a634527240039eb3bd650e5979d", size 
= 13264051, upload-time = "2025-12-18T19:29:13.431Z" },
+    { url = 
"https://files.pythonhosted.org/packages/08/64/8c3a47eaccfef8ac20e0484e68e0772013eb85802f8a9f7603ca751eb166/ruff-0.14.10-py3-none-musllinux_1_2_armv7l.whl";,
 hash = 
"sha256:1484983559f026788e3a5c07c81ef7d1e97c1c78ed03041a18f75df104c45405", size 
= 13283998, upload-time = "2025-12-18T19:29:06.994Z" },
+    { url = 
"https://files.pythonhosted.org/packages/12/84/534a5506f4074e5cc0529e5cd96cfc01bb480e460c7edf5af70d2bcae55e/ruff-0.14.10-py3-none-musllinux_1_2_i686.whl";,
 hash = 
"sha256:c70427132db492d25f982fffc8d6c7535cc2fd2c83fc8888f05caaa248521e60", size 
= 13601891, upload-time = "2025-12-18T19:28:55.811Z" },
+    { url = 
"https://files.pythonhosted.org/packages/0d/1e/14c916087d8598917dbad9b2921d340f7884824ad6e9c55de948a93b106d/ruff-0.14.10-py3-none-musllinux_1_2_x86_64.whl";,
 hash = 
"sha256:5bcf45b681e9f1ee6445d317ce1fa9d6cba9a6049542d1c3d5b5958986be8830", size 
= 14336660, upload-time = "2025-12-18T19:29:16.531Z" },
+    { url = 
"https://files.pythonhosted.org/packages/f2/1c/d7b67ab43f30013b47c12b42d1acd354c195351a3f7a1d67f59e54227ede/ruff-0.14.10-py3-none-win32.whl";,
 hash = 
"sha256:104c49fc7ab73f3f3a758039adea978869a918f31b73280db175b43a2d9b51d6", size 
= 13196187, upload-time = "2025-12-18T19:29:19.006Z" },
+    { url = 
"https://files.pythonhosted.org/packages/fb/9c/896c862e13886fae2af961bef3e6312db9ebc6adc2b156fe95e615dee8c1/ruff-0.14.10-py3-none-win_amd64.whl";,
 hash = 
"sha256:466297bd73638c6bdf06485683e812db1c00c7ac96d4ddd0294a338c62fdc154", size 
= 14661283, upload-time = "2025-12-18T19:29:30.16Z" },
+    { url = 
"https://files.pythonhosted.org/packages/74/31/b0e29d572670dca3674eeee78e418f20bdf97fa8aa9ea71380885e175ca0/ruff-0.14.10-py3-none-win_arm64.whl";,
 hash = 
"sha256:e51d046cf6dda98a4633b8a8a771451107413b0f07183b2bef03f075599e44e6", size 
= 13729839, upload-time = "2025-12-18T19:28:48.636Z" },
+]
+
+[[package]]
+name = "semver"
+version = "3.0.4"
+source = { registry = "https://pypi.org/simple"; }
+sdist = { url = 
"https://files.pythonhosted.org/packages/72/d1/d3159231aec234a59dd7d601e9dd9fe96f3afff15efd33c1070019b26132/semver-3.0.4.tar.gz";,
 hash = 
"sha256:afc7d8c584a5ed0a11033af086e8af226a9c0b206f313e0301f8dd7b6b589602", size 
= 269730, upload-time = "2025-01-24T13:19:27.617Z" }
+wheels = [
+    { url = 
"https://files.pythonhosted.org/packages/a6/24/4d91e05817e92e3a61c8a21e08fd0f390f5301f1c448b137c57c4bc6e543/semver-3.0.4-py3-none-any.whl";,
 hash = 
"sha256:9c824d87ba7f7ab4a1890799cec8596f15c1241cb473404ea1cb0c55e4b04746", size 
= 17912, upload-time = "2025-01-24T13:19:24.949Z" },
 ]
 
 [[package]]
@@ -1793,6 +1802,7 @@ dependencies = [
     { name = "quart-schema", extra = ["pydantic"] },
     { name = "quart-wtforms" },
     { name = "rich" },
+    { name = "semver" },
     { name = "sqlmodel" },
     { name = "standard-imghdr" },
     { name = "yyjson" },
@@ -1850,6 +1860,7 @@ requires-dist = [
     { name = "quart-schema", extras = ["pydantic"], specifier = "~=0.21" },
     { name = "quart-wtforms", specifier = "~=1.0.3" },
     { name = "rich", specifier = "~=14.0.0" },
+    { name = "semver", specifier = ">=3.0.4" },
     { name = "sqlmodel", specifier = "~=0.0.24" },
     { name = "standard-imghdr", specifier = ">=3.13.0" },
     { name = "yyjson", specifier = ">=4.0.6" },


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to