bompage 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
bompage/__init__.py ADDED
File without changes
bompage/_git.py ADDED
@@ -0,0 +1,72 @@
1
+ """Shared helpers for shelling out to ``git`` against the central bompage
2
+ repository (spec sections 5 and 5.4).
3
+
4
+ Both :mod:`bompage.push` and :mod:`bompage.prune` clone the central repo with a
5
+ scoped token, modify ``reports/`` and push. The token is handed to git through a
6
+ one-shot ``credential.helper`` that reads it from the child environment, so it
7
+ never lands on a command line (visible to ``ps`` on a shared runner) nor in the
8
+ clone's ``.git/config``.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import subprocess
15
+ from pathlib import Path
16
+
17
+ DEFAULT_BRANCH = "main"
18
+ DEFAULT_GIT_USER = "bompage-ci"
19
+ DEFAULT_GIT_EMAIL = "bompage-ci@localhost"
20
+
21
+ _TOKEN_ENV = "BOMPAGE_GIT_TOKEN"
22
+ # git runs this with the credential operation as $1 and reads the answer from
23
+ # stdout; the password comes from the environment, never from argv.
24
+ _CREDENTIAL_HELPER = (
25
+ f'!f() {{ echo "username=x-access-token"; echo "password=${_TOKEN_ENV}"; }}; f'
26
+ )
27
+
28
+
29
+ class GitRepoError(RuntimeError):
30
+ """A git step (clone, rm, commit, push) failed."""
31
+
32
+
33
+ def _redact(text: str, token: str | None) -> str:
34
+ """Blank out the token wherever git may have echoed it back."""
35
+ return text.replace(token, "***") if token else text
36
+
37
+
38
+ def _git_env(token: str | None) -> dict[str, str]:
39
+ """Child environment for git: never prompt, carry the token out of argv."""
40
+ env = {**os.environ, "GIT_TERMINAL_PROMPT": "0"}
41
+ if token:
42
+ env[_TOKEN_ENV] = token
43
+ return env
44
+
45
+
46
+ def _run_git(
47
+ args: list[str], *, cwd: Path | None = None, token: str | None = None
48
+ ) -> None:
49
+ """Run ``git <args>``; raise :class:`GitRepoError` (token redacted) on failure.
50
+
51
+ Every call resets any inherited ``credential.helper`` and installs the
52
+ env-backed one, so clone and push authenticate without the token ever
53
+ reaching the command line.
54
+ """
55
+ proc = subprocess.run(
56
+ [
57
+ "git",
58
+ "-c",
59
+ "credential.helper=",
60
+ "-c",
61
+ f"credential.helper={_CREDENTIAL_HELPER}",
62
+ *args,
63
+ ],
64
+ cwd=cwd,
65
+ capture_output=True,
66
+ text=True,
67
+ check=False,
68
+ env=_git_env(token),
69
+ )
70
+ if proc.returncode != 0:
71
+ detail = _redact((proc.stderr or proc.stdout).strip(), token)
72
+ raise GitRepoError(f"git {args[0]} failed: {detail}")
bompage/_time.py ADDED
@@ -0,0 +1,20 @@
1
+ """Clock helper shared by the commands that derive a calendar day from ``now``.
2
+
3
+ ``build`` (freshness verdict and the recent-changes window), ``push`` (the
4
+ ``sbom-YYYYMMDD`` file stamp) and ``prune`` (the retention cutoff) all turn an
5
+ instant into a UTC calendar day. Routing every one through :func:`as_utc` keeps
6
+ that day independent of the caller's timezone -- a naive datetime is assumed to
7
+ already be UTC, an aware one is converted.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from datetime import UTC, datetime
13
+
14
+
15
+ def as_utc(now: datetime | None) -> datetime:
16
+ """Resolve ``now`` (or the current instant) to an aware UTC datetime."""
17
+ resolved = now or datetime.now(tz=UTC)
18
+ if resolved.tzinfo is None:
19
+ return resolved.replace(tzinfo=UTC)
20
+ return resolved.astimezone(UTC)
File without changes
@@ -0,0 +1,71 @@
1
+ """Cross-component inverted index and version-drift detection.
2
+
3
+ ``build_package_index`` maps every package name to the list of components that
4
+ carry it, with the version and ecosystem seen on each (spec section 7.3). That
5
+ index powers the transverse search and, via ``active_drifts``, the list of
6
+ dependencies that sit at diverging versions across components (spec section
7
+ 7.4), each dated by when the divergence last widened so the dashboard can sort
8
+ by how long it has stood.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from datetime import date
14
+
15
+ from bompage.models import ComponentReport, Drift, PackageOccurrence
16
+
17
+ # A drift needs the package on at least this many components, at this many
18
+ # distinct versions.
19
+ _MIN_FOR_DRIFT = 2
20
+
21
+
22
+ def build_package_index(
23
+ components: list[ComponentReport],
24
+ ) -> dict[str, list[PackageOccurrence]]:
25
+ """Map each package name to one occurrence per component that carries it.
26
+
27
+ A component may list the same name at several versions (spec section 7.3
28
+ speaks of "the version detected on each", singular); keep the highest so a
29
+ within-component duplicate is not mistaken for a cross-component drift.
30
+ """
31
+ index: dict[str, list[PackageOccurrence]] = {}
32
+ for component in sorted(components, key=lambda c: c.name):
33
+ best: dict[str, PackageOccurrence] = {}
34
+ for pkg in component.packages:
35
+ current = best.get(pkg.name)
36
+ if current is None or (pkg.version or "") > (current.version or ""):
37
+ best[pkg.name] = PackageOccurrence(
38
+ component=component.name,
39
+ version=pkg.version,
40
+ ecosystem=pkg.ecosystem,
41
+ )
42
+ for name, occurrence in best.items():
43
+ index.setdefault(name, []).append(occurrence)
44
+ return index
45
+
46
+
47
+ def _current_version_date(component: ComponentReport, name: str) -> date:
48
+ history = component.timeline.get(name)
49
+ if history:
50
+ return history[-1].first_seen
51
+ return component.last_scan_date # pragma: no cover - guard for inconsistent input
52
+
53
+
54
+ def active_drifts(
55
+ index: dict[str, list[PackageOccurrence]],
56
+ components: list[ComponentReport],
57
+ ) -> list[Drift]:
58
+ """Packages present on 2+ components at 2+ distinct versions."""
59
+ by_name = {component.name: component for component in components}
60
+ drifts: list[Drift] = []
61
+ for name, occurrences in index.items():
62
+ if len(occurrences) < _MIN_FOR_DRIFT:
63
+ continue
64
+ if len({occ.version for occ in occurrences}) < _MIN_FOR_DRIFT:
65
+ continue
66
+ since = max(
67
+ _current_version_date(by_name[occ.component], name) for occ in occurrences
68
+ )
69
+ drifts.append(Drift(name=name, entries=list(occurrences), since=since))
70
+ drifts.sort(key=lambda d: (d.since, d.name))
71
+ return drifts
@@ -0,0 +1,30 @@
1
+ """Data-freshness evaluation for a component (spec section 7.5).
2
+
3
+ Turns a component's most recent scan date plus a staleness threshold into a
4
+ ``Freshness`` verdict, so the dashboard can tell "no dependency change" apart
5
+ from "the source pipeline stopped pushing".
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from datetime import UTC, date, datetime
11
+
12
+ from bompage.models import Freshness
13
+
14
+
15
+ def evaluate(last_scan: date, *, now: datetime, stale_after: int) -> Freshness:
16
+ """Days since the last report, and whether that exceeds ``stale_after``.
17
+
18
+ ``now`` is taken in UTC (an aware datetime in any zone is converted first, a
19
+ naive one is assumed to already be UTC), so the day boundary is fixed
20
+ regardless of the caller's local time and a scan stamped "today" never reads
21
+ as one day stale near midnight.
22
+ """
23
+ now_utc = now if now.tzinfo is not None else now.replace(tzinfo=UTC)
24
+ # Clamp a future-dated scan (source clock ahead) to 0 rather than show "-3d".
25
+ days_since_scan = max(0, (now_utc.astimezone(UTC).date() - last_scan).days)
26
+ return Freshness(
27
+ last_scan_date=last_scan,
28
+ days_since_scan=days_since_scan,
29
+ is_stale=days_since_scan > stale_after,
30
+ )
@@ -0,0 +1,85 @@
1
+ """Current-inventory computation for a component.
2
+
3
+ Builds ``current_inventory`` from the latest snapshot: package name, version,
4
+ ecosystem inferred from the ``purl``, and licence, flagging strict copyleft
5
+ licences for the dashboard (spec sections 6.1 point 4 and 7.1).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from functools import cache
12
+
13
+ from bompage.models import Package
14
+
15
+ # purl type -> human label shown in the dashboard filter (spec section 7.1).
16
+ _ECOSYSTEMS = {
17
+ "pypi": "PyPI",
18
+ "npm": "npm",
19
+ "deb": "Deb",
20
+ "apk": "Alpine",
21
+ "rpm": "RPM",
22
+ "cargo": "Cargo",
23
+ "gem": "RubyGems",
24
+ "golang": "Go",
25
+ "maven": "Maven",
26
+ "nuget": "NuGet",
27
+ "composer": "Composer",
28
+ "hex": "Hex",
29
+ "oci": "OCI",
30
+ "cran": "CRAN",
31
+ }
32
+
33
+ _PURL_TYPE_RE = re.compile(r"^pkg:(?P<type>[^/]+)/")
34
+
35
+ # Strong ("strict") copyleft families flagged in the dashboard (spec section 7.1).
36
+ # GPL/AGPL are handled separately so the LGPL exclusion stays correct.
37
+ _STRONG_COPYLEFT = ("EUPL", "OSL", "CECILL", "SSPL", "RPL", "CPAL")
38
+
39
+
40
+ def _ecosystem(purl: str | None) -> str | None:
41
+ if not purl:
42
+ return None
43
+ match = _PURL_TYPE_RE.match(purl)
44
+ if match is None:
45
+ return None
46
+ purl_type = match["type"].lower()
47
+ # Known types get a curated label; any other valid purl type is still shown
48
+ # and filterable under its own name rather than dropped.
49
+ return _ECOSYSTEMS.get(purl_type, purl_type.title())
50
+
51
+
52
+ # Memoised: pure, and called once per package on the latest snapshot of every
53
+ # component with a very small set of distinct licence strings (``MIT``,
54
+ # ``Apache-2.0``, …). The normalisation below (upper + regex strip + scan) is
55
+ # the priciest bit of inventory enrichment; the process is short-lived so an
56
+ # unbounded cache is safe.
57
+ @cache
58
+ def _is_strong_copyleft(license_id: str | None) -> bool:
59
+ if not license_id:
60
+ return False
61
+ norm = re.sub(r"[^A-Z]", "", license_id.upper())
62
+ # CeCILL-B is BSD-style permissive and CeCILL-C is LGPL-style weak copyleft;
63
+ # only plain CeCILL (v1 / v2 / v2.1) is strong. Drop the weak variants
64
+ # before the scan, the same way LGPL is excluded from the GPL test below.
65
+ norm = norm.replace("CECILLB", "").replace("CECILLC", "")
66
+ if any(token in norm for token in _STRONG_COPYLEFT):
67
+ return True
68
+ # GPL / AGPL but not (only) LGPL: drop LGPL runs before looking for GPL.
69
+ return "GPL" in norm.replace("LGPL", "")
70
+
71
+
72
+ def build_inventory(packages: list[Package]) -> list[Package]:
73
+ """Return the deduplicated, ecosystem-enriched inventory, sorted by name."""
74
+ seen: dict[tuple[str, str | None, str | None], Package] = {}
75
+ for pkg in packages:
76
+ key = (pkg.name, pkg.version, pkg.purl)
77
+ if key in seen:
78
+ continue
79
+ seen[key] = pkg.model_copy(
80
+ update={
81
+ "ecosystem": _ecosystem(pkg.purl),
82
+ "is_strong_copyleft": _is_strong_copyleft(pkg.license),
83
+ }
84
+ )
85
+ return sorted(seen.values(), key=lambda p: (p.name.lower(), p.version or ""))
@@ -0,0 +1,120 @@
1
+ """Version history derived from a component's dated snapshots.
2
+
3
+ - ``build_timeline`` : per package, the sequence of versions it held, a new
4
+ entry only when the version differs from the last one recorded (spec section
5
+ 6.1 point 4).
6
+ - ``diff_last_two`` : the explicit ``added`` / ``removed`` lists between the two
7
+ most recent snapshots.
8
+ - ``recent_changes`` : every add / remove / version change whose date falls in a
9
+ sliding window (spec section 7.2 "changements récents").
10
+
11
+ All three key off the same per-snapshot ``{name: version}`` view
12
+ (:func:`snapshot_versions`). The build passes that view in precomputed via the
13
+ ``versions`` keyword so it is built once per snapshot rather than once per
14
+ function; callers that omit it get it computed on the fly.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from datetime import date
20
+ from itertools import pairwise
21
+
22
+ from bompage.models import Change, Package, Snapshot, TimelineEntry
23
+
24
+ _MIN_SNAPSHOTS_FOR_DIFF = 2
25
+
26
+
27
+ def snapshot_versions(snapshot: Snapshot) -> dict[str, str | None]:
28
+ """Map each package name to its version in this snapshot.
29
+
30
+ A name may legitimately appear more than once in one snapshot (e.g. an npm
31
+ tree that carries two versions of the same package). Keep the highest
32
+ version -- same ``version or ""`` ordering the inventory sorts by -- so the
33
+ timeline and the added/removed deltas stay deterministic instead of
34
+ depending on package order.
35
+ """
36
+ versions: dict[str, str | None] = {}
37
+ for pkg in snapshot.packages:
38
+ if pkg.name not in versions or (pkg.version or "") > (versions[pkg.name] or ""):
39
+ versions[pkg.name] = pkg.version
40
+ return versions
41
+
42
+
43
+ def _resolve(
44
+ snapshots: list[Snapshot], versions: list[dict[str, str | None]] | None
45
+ ) -> list[dict[str, str | None]]:
46
+ if versions is None:
47
+ return [snapshot_versions(snapshot) for snapshot in snapshots]
48
+ if len(versions) != len(snapshots):
49
+ msg = "versions must have one entry per snapshot"
50
+ raise ValueError(msg)
51
+ return versions
52
+
53
+
54
+ def build_timeline(
55
+ snapshots: list[Snapshot],
56
+ *,
57
+ versions: list[dict[str, str | None]] | None = None,
58
+ ) -> dict[str, list[TimelineEntry]]:
59
+ """Map each package name to its ordered list of version changes."""
60
+ per_snapshot = _resolve(snapshots, versions)
61
+ timeline: dict[str, list[TimelineEntry]] = {}
62
+ for snapshot, snapshot_view in zip(snapshots, per_snapshot, strict=True):
63
+ for name, version in snapshot_view.items():
64
+ history = timeline.setdefault(name, [])
65
+ if not history or history[-1].version != version:
66
+ history.append(TimelineEntry(version=version, first_seen=snapshot.date))
67
+ return timeline
68
+
69
+
70
+ def diff_last_two(
71
+ snapshots: list[Snapshot],
72
+ *,
73
+ versions: list[dict[str, str | None]] | None = None,
74
+ ) -> tuple[list[Package], list[str]]:
75
+ """Packages added and package names removed between the last two snapshots."""
76
+ if len(snapshots) < _MIN_SNAPSHOTS_FOR_DIFF:
77
+ return [], []
78
+ per_snapshot = _resolve(snapshots, versions)
79
+ previous = per_snapshot[-2]
80
+ current = {pkg.name: pkg for pkg in snapshots[-1].packages}
81
+ added = [pkg for name, pkg in current.items() if name not in previous]
82
+ removed = [name for name in previous if name not in current]
83
+ return added, sorted(removed)
84
+
85
+
86
+ def recent_changes(
87
+ snapshots: list[Snapshot],
88
+ *,
89
+ since: date,
90
+ versions: list[dict[str, str | None]] | None = None,
91
+ ) -> list[Change]:
92
+ """Add / remove / version-change events dated on or after ``since``."""
93
+ per_snapshot = _resolve(snapshots, versions)
94
+ changes: list[Change] = []
95
+ paired = zip(snapshots, per_snapshot, strict=True)
96
+ for (_previous, before), (current, after) in pairwise(paired):
97
+ if current.date < since:
98
+ continue
99
+ for name, version in after.items():
100
+ if name not in before:
101
+ changes.append(
102
+ Change(
103
+ kind="added", package=name, version=version, date=current.date
104
+ )
105
+ )
106
+ elif before[name] != version:
107
+ changes.append(
108
+ Change(
109
+ kind="changed",
110
+ package=name,
111
+ version=version,
112
+ previous_version=before[name],
113
+ date=current.date,
114
+ )
115
+ )
116
+ for name in before:
117
+ if name not in after:
118
+ changes.append(Change(kind="removed", package=name, date=current.date))
119
+ changes.sort(key=lambda c: (c.date, c.package, c.kind))
120
+ return changes
bompage/bompage.py ADDED
@@ -0,0 +1,276 @@
1
+ """Command-line entry point for ``bompage``.
2
+
3
+ Defines the ``app`` Typer application, the ``build`` command that turns a
4
+ ``reports/`` tree (one directory per component) into the constant dashboard
5
+ assets, the ``data/`` JSON tree they consume at runtime and a copy of each
6
+ component's latest raw manifest (spec section 6.1), and the ``push`` command
7
+ that publishes one SBOM into the central repository (spec sections 5 / 9).
8
+ Version string ``1.0.0`` is substituted at release time.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from pathlib import Path
14
+ from typing import Annotated
15
+
16
+ import typer
17
+
18
+ from bompage._git import (
19
+ DEFAULT_BRANCH,
20
+ DEFAULT_GIT_EMAIL,
21
+ DEFAULT_GIT_USER,
22
+ GitRepoError,
23
+ )
24
+ from bompage.build import build_site
25
+ from bompage.metadata import parse_component, parse_global
26
+ from bompage.prune import prune_report
27
+ from bompage.push import PushError, push_report
28
+
29
+ __version__ = "1.0.0"
30
+
31
+ app = typer.Typer(
32
+ add_completion=False,
33
+ help="Track SBOM history of N components and build a static dashboard.",
34
+ )
35
+
36
+
37
+ def _version(value: bool) -> None:
38
+ if value:
39
+ typer.echo(__version__)
40
+ raise typer.Exit()
41
+
42
+
43
+ @app.callback()
44
+ def main(
45
+ _version_flag: Annotated[
46
+ bool,
47
+ typer.Option(
48
+ "--version",
49
+ callback=_version,
50
+ is_eager=True,
51
+ help="Show the version and exit.",
52
+ ),
53
+ ] = False,
54
+ ) -> None:
55
+ """bompage command-line interface."""
56
+
57
+
58
+ @app.command()
59
+ def build(
60
+ reports: Annotated[
61
+ Path,
62
+ typer.Option(
63
+ exists=True,
64
+ file_okay=False,
65
+ dir_okay=True,
66
+ help="Directory holding one sub-directory per component.",
67
+ ),
68
+ ] = Path("reports"),
69
+ output: Annotated[
70
+ Path,
71
+ typer.Option(help="Directory the static site is written to."),
72
+ ] = Path("public"),
73
+ web_dir: Annotated[
74
+ Path | None,
75
+ typer.Option(
76
+ exists=True,
77
+ file_okay=False,
78
+ help="Directory of a pre-built dashboard (index.html + assets/) to "
79
+ "use instead of the packaged one.",
80
+ ),
81
+ ] = None,
82
+ stale_after: Annotated[
83
+ int,
84
+ typer.Option(
85
+ min=0,
86
+ help="A component with no new report for more than this many days "
87
+ "is flagged stale.",
88
+ ),
89
+ ] = 2,
90
+ recent_days: Annotated[
91
+ int,
92
+ typer.Option(
93
+ min=1,
94
+ help="Sliding window (days) for the dashboard's recent-changes feed.",
95
+ ),
96
+ ] = 30,
97
+ metadata: Annotated[
98
+ list[str] | None,
99
+ typer.Option(
100
+ "--metadata",
101
+ help='Global metadata row "key=value" for the dashboard header; '
102
+ "repeatable.",
103
+ ),
104
+ ] = None,
105
+ metadata_component: Annotated[
106
+ list[str] | None,
107
+ typer.Option(
108
+ "--metadata-component",
109
+ help='Per-component metadata row "component:key=value"; repeatable.',
110
+ ),
111
+ ] = None,
112
+ ) -> None:
113
+ """Build the static dashboard and its data/ tree from a reports/ tree."""
114
+ try:
115
+ global_metadata = parse_global(metadata or [])
116
+ by_component = parse_component(metadata_component or [])
117
+ except ValueError as err:
118
+ raise typer.BadParameter(str(err)) from err
119
+ data = build_site(
120
+ reports,
121
+ output,
122
+ web_dir=web_dir,
123
+ stale_after=stale_after,
124
+ recent_days=recent_days,
125
+ metadata=global_metadata,
126
+ component_metadata=by_component,
127
+ )
128
+ total_packages = sum(len(c.packages) for c in data.components)
129
+ stale = [c.name for c in data.components if c.freshness.is_stale]
130
+ typer.echo(
131
+ f"{len(data.components)} component(s), {total_packages} package(s), "
132
+ f"{len(data.drifts)} drift(s) -> {output}"
133
+ )
134
+ if stale:
135
+ typer.echo(f" stale: {', '.join(stale)}")
136
+ for component in data.components:
137
+ typer.echo(f" {component.name}: {len(component.packages)} packages")
138
+
139
+
140
+ @app.command()
141
+ def push(
142
+ component: Annotated[
143
+ str,
144
+ typer.Option(help="Logical component name (one directory under reports/)."),
145
+ ],
146
+ sbom: Annotated[
147
+ Path,
148
+ typer.Option(
149
+ exists=True,
150
+ dir_okay=False,
151
+ help="Path to the SBOM file produced by the calling job.",
152
+ ),
153
+ ],
154
+ repo: Annotated[
155
+ str,
156
+ typer.Option(help="HTTPS clone URL of the central bompage repository."),
157
+ ],
158
+ token: Annotated[
159
+ str,
160
+ typer.Option(
161
+ envvar="BOMPAGE_TOKEN",
162
+ help="Scoped write token for the central repository.",
163
+ ),
164
+ ],
165
+ branch: Annotated[
166
+ str,
167
+ typer.Option(help="Target branch in the central repository."),
168
+ ] = DEFAULT_BRANCH,
169
+ sbom_version: Annotated[
170
+ str | None,
171
+ typer.Option(help="Version suffix for the SBOM file name."),
172
+ ] = None,
173
+ sbom_format: Annotated[
174
+ str | None,
175
+ typer.Option(help="Informative (spdx | cyclonedx); recorded in the commit."),
176
+ ] = None,
177
+ git_user: Annotated[
178
+ str, typer.Option(help="Commit author name.")
179
+ ] = DEFAULT_GIT_USER,
180
+ git_email: Annotated[
181
+ str, typer.Option(help="Commit author email.")
182
+ ] = DEFAULT_GIT_EMAIL,
183
+ ) -> None:
184
+ """Publish one SBOM into the central repository.
185
+
186
+ Clones the central repo, drops the SBOM, commits only if it changed, pushes.
187
+ """
188
+ try:
189
+ outcome = push_report(
190
+ component=component,
191
+ sbom=sbom,
192
+ repo=repo,
193
+ token=token,
194
+ branch=branch,
195
+ sbom_version=sbom_version or None,
196
+ sbom_format=sbom_format or None,
197
+ git_user=git_user,
198
+ git_email=git_email,
199
+ )
200
+ except PushError as err:
201
+ typer.echo(f"push: {err}", err=True)
202
+ raise typer.Exit(1) from err
203
+
204
+ if outcome == "unchanged":
205
+ typer.echo(f"push: {component} unchanged, nothing to commit")
206
+ else:
207
+ typer.echo(f"push: committed {component}")
208
+
209
+
210
+ @app.command()
211
+ def prune(
212
+ repo: Annotated[
213
+ str,
214
+ typer.Option(help="HTTPS clone URL of the central bompage repository."),
215
+ ],
216
+ token: Annotated[
217
+ str,
218
+ typer.Option(
219
+ envvar="BOMPAGE_TOKEN",
220
+ help="Scoped write token for the central repository.",
221
+ ),
222
+ ],
223
+ branch: Annotated[
224
+ str,
225
+ typer.Option(help="Target branch in the central repository."),
226
+ ] = DEFAULT_BRANCH,
227
+ keep_days: Annotated[
228
+ int,
229
+ typer.Option(
230
+ min=1,
231
+ help="Snapshots older than this many days are removed; the most "
232
+ "recent snapshot of each component is always kept.",
233
+ ),
234
+ ] = 90,
235
+ dry_run: Annotated[
236
+ bool,
237
+ typer.Option(help="List what would be removed without committing."),
238
+ ] = False,
239
+ git_user: Annotated[
240
+ str, typer.Option(help="Commit author name.")
241
+ ] = DEFAULT_GIT_USER,
242
+ git_email: Annotated[
243
+ str, typer.Option(help="Commit author email.")
244
+ ] = DEFAULT_GIT_EMAIL,
245
+ ) -> None:
246
+ """Remove SBOM snapshots older than --keep-days from the central repository.
247
+
248
+ Clones the central repo, deletes the stale snapshot files in a single
249
+ ordinary commit (no history rewrite) and pushes (spec section 5.4).
250
+ """
251
+ try:
252
+ outcome, paths = prune_report(
253
+ repo=repo,
254
+ token=token,
255
+ branch=branch,
256
+ keep_days=keep_days,
257
+ dry_run=dry_run,
258
+ git_user=git_user,
259
+ git_email=git_email,
260
+ )
261
+ except GitRepoError as err:
262
+ typer.echo(f"prune: {err}", err=True)
263
+ raise typer.Exit(1) from err
264
+
265
+ if outcome == "nothing":
266
+ typer.echo(f"prune: nothing older than {keep_days} days")
267
+ return
268
+ if outcome == "would-prune":
269
+ for path in paths:
270
+ typer.echo(f" {path}")
271
+ typer.echo(f"prune: {len(paths)} file(s) would be removed (dry run)")
272
+ return
273
+ components = len({Path(path).parent.name for path in paths})
274
+ typer.echo(
275
+ f"prune: removed {len(paths)} snapshot(s) across {components} component(s)"
276
+ )