bompage 1.2.2__tar.gz → 1.3.0__tar.gz

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.
Files changed (144) hide show
  1. {bompage-1.2.2 → bompage-1.3.0}/.gitignore +1 -0
  2. {bompage-1.2.2 → bompage-1.3.0}/.gitlab-ci.yml +75 -57
  3. {bompage-1.2.2 → bompage-1.3.0}/PKG-INFO +10 -8
  4. {bompage-1.2.2 → bompage-1.3.0}/README.md +9 -7
  5. bompage-1.3.0/deploy/.gitlab-ci.yml +55 -0
  6. {bompage-1.2.2 → bompage-1.3.0}/deploy/README.md +26 -30
  7. {bompage-1.2.2 → bompage-1.3.0}/deploy/trust/allowed_signers +5 -3
  8. {bompage-1.2.2 → bompage-1.3.0}/docs/index.md +1 -1
  9. {bompage-1.2.2 → bompage-1.3.0}/pyproject.toml +4 -4
  10. {bompage-1.2.2 → bompage-1.3.0}/renovate.json +2 -2
  11. bompage-1.3.0/src/bompage/_paths.py +17 -0
  12. {bompage-1.2.2 → bompage-1.3.0}/src/bompage/analysis/drift.py +10 -9
  13. {bompage-1.2.2 → bompage-1.3.0}/src/bompage/analysis/inventory.py +14 -2
  14. {bompage-1.2.2 → bompage-1.3.0}/src/bompage/analysis/timeline.py +7 -5
  15. {bompage-1.2.2 → bompage-1.3.0}/src/bompage/attest.py +34 -30
  16. {bompage-1.2.2 → bompage-1.3.0}/src/bompage/bompage.py +5 -4
  17. {bompage-1.2.2 → bompage-1.3.0}/src/bompage/build.py +31 -15
  18. {bompage-1.2.2 → bompage-1.3.0}/src/bompage/metadata.py +2 -1
  19. {bompage-1.2.2 → bompage-1.3.0}/src/bompage/models.py +2 -1
  20. {bompage-1.2.2 → bompage-1.3.0}/src/bompage/parsers/base.py +29 -1
  21. {bompage-1.2.2 → bompage-1.3.0}/src/bompage/parsers/cyclonedx.py +29 -3
  22. {bompage-1.2.2 → bompage-1.3.0}/src/bompage/push.py +112 -40
  23. bompage-1.2.2/src/bompage/web/assets/index-D_clWxBW.js → bompage-1.3.0/src/bompage/web/assets/index-BQwfQt1A.js +71 -71
  24. {bompage-1.2.2 → bompage-1.3.0}/src/bompage/web/index.html +2 -1
  25. {bompage-1.2.2 → bompage-1.3.0}/tests/analysis/test_inventory.py +31 -0
  26. {bompage-1.2.2 → bompage-1.3.0}/tests/parsers/test_base.py +48 -0
  27. {bompage-1.2.2 → bompage-1.3.0}/tests/parsers/test_cyclonedx.py +19 -0
  28. {bompage-1.2.2 → bompage-1.3.0}/tests/test_attest.py +15 -0
  29. {bompage-1.2.2 → bompage-1.3.0}/tests/test_build.py +14 -0
  30. {bompage-1.2.2 → bompage-1.3.0}/tests/test_prune_report.py +7 -4
  31. {bompage-1.2.2 → bompage-1.3.0}/tests/test_push_report.py +109 -45
  32. {bompage-1.2.2 → bompage-1.3.0}/web-src/app.test.ts +42 -0
  33. {bompage-1.2.2 → bompage-1.3.0}/web-src/app.ts +25 -9
  34. {bompage-1.2.2 → bompage-1.3.0}/web-src/index.html +1 -0
  35. {bompage-1.2.2 → bompage-1.3.0}/web-src/lib.test.ts +121 -2
  36. {bompage-1.2.2 → bompage-1.3.0}/web-src/lib.ts +59 -4
  37. bompage-1.2.2/deploy/.gitlab-ci.yml +0 -99
  38. bompage-1.2.2/templates/push-report.yml +0 -158
  39. {bompage-1.2.2 → bompage-1.3.0}/.gitleaks.toml +0 -0
  40. {bompage-1.2.2 → bompage-1.3.0}/.markdownlint.yaml +0 -0
  41. {bompage-1.2.2 → bompage-1.3.0}/.node-version +0 -0
  42. {bompage-1.2.2 → bompage-1.3.0}/.pre-commit-config.yaml +0 -0
  43. {bompage-1.2.2 → bompage-1.3.0}/.python-version +0 -0
  44. {bompage-1.2.2 → bompage-1.3.0}/.releaserc +0 -0
  45. {bompage-1.2.2 → bompage-1.3.0}/.yamllint +0 -0
  46. {bompage-1.2.2 → bompage-1.3.0}/CONTRIBUTING.md +0 -0
  47. {bompage-1.2.2 → bompage-1.3.0}/LICENSE +0 -0
  48. {bompage-1.2.2 → bompage-1.3.0}/SECURITY.md +0 -0
  49. {bompage-1.2.2 → bompage-1.3.0}/Taskfile.project.yml +0 -0
  50. {bompage-1.2.2 → bompage-1.3.0}/Taskfile.yml +0 -0
  51. {bompage-1.2.2 → bompage-1.3.0}/biome.json +0 -0
  52. {bompage-1.2.2 → bompage-1.3.0}/deploy/reports/example-component/.gitkeep +0 -0
  53. {bompage-1.2.2 → bompage-1.3.0}/deploy/web/.gitkeep +0 -0
  54. {bompage-1.2.2 → bompage-1.3.0}/docs/analysis/drift.md +0 -0
  55. {bompage-1.2.2 → bompage-1.3.0}/docs/analysis/freshness.md +0 -0
  56. {bompage-1.2.2 → bompage-1.3.0}/docs/analysis/inventory.md +0 -0
  57. {bompage-1.2.2 → bompage-1.3.0}/docs/analysis/timeline.md +0 -0
  58. {bompage-1.2.2 → bompage-1.3.0}/docs/bompage.md +0 -0
  59. {bompage-1.2.2 → bompage-1.3.0}/docs/build.md +0 -0
  60. {bompage-1.2.2 → bompage-1.3.0}/docs/cli.md +0 -0
  61. {bompage-1.2.2 → bompage-1.3.0}/docs/dashboard.md +0 -0
  62. {bompage-1.2.2 → bompage-1.3.0}/docs/discovery.md +0 -0
  63. {bompage-1.2.2 → bompage-1.3.0}/docs/metadata.md +0 -0
  64. {bompage-1.2.2 → bompage-1.3.0}/docs/models.md +0 -0
  65. {bompage-1.2.2 → bompage-1.3.0}/docs/parsers/base.md +0 -0
  66. {bompage-1.2.2 → bompage-1.3.0}/docs/parsers/cyclonedx.md +0 -0
  67. {bompage-1.2.2 → bompage-1.3.0}/docs/parsers/spdx.md +0 -0
  68. {bompage-1.2.2 → bompage-1.3.0}/docs/prune.md +0 -0
  69. {bompage-1.2.2 → bompage-1.3.0}/docs/push.md +0 -0
  70. {bompage-1.2.2 → bompage-1.3.0}/docs/render/api.md +0 -0
  71. {bompage-1.2.2 → bompage-1.3.0}/docs/render/data.md +0 -0
  72. {bompage-1.2.2 → bompage-1.3.0}/docs/render/web.md +0 -0
  73. {bompage-1.2.2 → bompage-1.3.0}/docs/verify.md +0 -0
  74. {bompage-1.2.2 → bompage-1.3.0}/lychee.toml +0 -0
  75. {bompage-1.2.2 → bompage-1.3.0}/mkdocs.yml +0 -0
  76. {bompage-1.2.2 → bompage-1.3.0}/package.json +0 -0
  77. {bompage-1.2.2 → bompage-1.3.0}/pnpm-lock.yaml +0 -0
  78. {bompage-1.2.2 → bompage-1.3.0}/pnpm-workspace.yaml +0 -0
  79. {bompage-1.2.2 → bompage-1.3.0}/scripts/gen-demo-data.py +0 -0
  80. {bompage-1.2.2 → bompage-1.3.0}/scripts/merge_sbom_licenses.py +0 -0
  81. {bompage-1.2.2 → bompage-1.3.0}/scripts/subset-icons.mjs +0 -0
  82. {bompage-1.2.2 → bompage-1.3.0}/src/bompage/__init__.py +0 -0
  83. {bompage-1.2.2 → bompage-1.3.0}/src/bompage/_git.py +0 -0
  84. {bompage-1.2.2 → bompage-1.3.0}/src/bompage/_time.py +0 -0
  85. {bompage-1.2.2 → bompage-1.3.0}/src/bompage/_version.py +0 -0
  86. {bompage-1.2.2 → bompage-1.3.0}/src/bompage/analysis/__init__.py +0 -0
  87. {bompage-1.2.2 → bompage-1.3.0}/src/bompage/analysis/freshness.py +0 -0
  88. {bompage-1.2.2 → bompage-1.3.0}/src/bompage/discovery.py +0 -0
  89. {bompage-1.2.2 → bompage-1.3.0}/src/bompage/parsers/__init__.py +0 -0
  90. {bompage-1.2.2 → bompage-1.3.0}/src/bompage/parsers/spdx.py +0 -0
  91. {bompage-1.2.2 → bompage-1.3.0}/src/bompage/prune.py +0 -0
  92. {bompage-1.2.2 → bompage-1.3.0}/src/bompage/py.typed +0 -0
  93. {bompage-1.2.2 → bompage-1.3.0}/src/bompage/render/__init__.py +0 -0
  94. {bompage-1.2.2 → bompage-1.3.0}/src/bompage/render/api.py +0 -0
  95. {bompage-1.2.2 → bompage-1.3.0}/src/bompage/render/data.py +0 -0
  96. {bompage-1.2.2 → bompage-1.3.0}/src/bompage/render/web.py +0 -0
  97. {bompage-1.2.2 → bompage-1.3.0}/src/bompage/web/android-chrome-192x192.png +0 -0
  98. {bompage-1.2.2 → bompage-1.3.0}/src/bompage/web/android-chrome-512x512.png +0 -0
  99. {bompage-1.2.2 → bompage-1.3.0}/src/bompage/web/apple-touch-icon.png +0 -0
  100. {bompage-1.2.2 → bompage-1.3.0}/src/bompage/web/assets/bompage-D8Wv-XTb.png +0 -0
  101. {bompage-1.2.2 → bompage-1.3.0}/src/bompage/web/assets/index-CBZgukme.css +0 -0
  102. {bompage-1.2.2 → bompage-1.3.0}/src/bompage/web/assets/material-symbols-outlined-CQbRTMbb.woff2 +0 -0
  103. {bompage-1.2.2 → bompage-1.3.0}/src/bompage/web/assets/roboto-latin-ext-wght-normal-DYIxWhlt.woff2 +0 -0
  104. {bompage-1.2.2 → bompage-1.3.0}/src/bompage/web/assets/roboto-latin-wght-normal-ccAYIvAh.woff2 +0 -0
  105. {bompage-1.2.2 → bompage-1.3.0}/src/bompage/web/favicon-16x16.png +0 -0
  106. {bompage-1.2.2 → bompage-1.3.0}/src/bompage/web/favicon-32x32.png +0 -0
  107. {bompage-1.2.2 → bompage-1.3.0}/src/bompage/web/favicon.ico +0 -0
  108. {bompage-1.2.2 → bompage-1.3.0}/src/bompage/web/site.webmanifest +0 -0
  109. {bompage-1.2.2 → bompage-1.3.0}/tests/__init__.py +0 -0
  110. {bompage-1.2.2 → bompage-1.3.0}/tests/analysis/__init__.py +0 -0
  111. {bompage-1.2.2 → bompage-1.3.0}/tests/analysis/test_drift.py +0 -0
  112. {bompage-1.2.2 → bompage-1.3.0}/tests/analysis/test_freshness.py +0 -0
  113. {bompage-1.2.2 → bompage-1.3.0}/tests/analysis/test_timeline.py +0 -0
  114. {bompage-1.2.2 → bompage-1.3.0}/tests/conftest.py +0 -0
  115. {bompage-1.2.2 → bompage-1.3.0}/tests/files/cyclonedx/simple.cdx.json +0 -0
  116. {bompage-1.2.2 → bompage-1.3.0}/tests/files/reports/app-api/sbom-20260115.json +0 -0
  117. {bompage-1.2.2 → bompage-1.3.0}/tests/files/spdx/simple.spdx.json +0 -0
  118. {bompage-1.2.2 → bompage-1.3.0}/tests/files/spdx/web-frontend.spdx.json +0 -0
  119. {bompage-1.2.2 → bompage-1.3.0}/tests/parsers/__init__.py +0 -0
  120. {bompage-1.2.2 → bompage-1.3.0}/tests/parsers/test_spdx.py +0 -0
  121. {bompage-1.2.2 → bompage-1.3.0}/tests/render/__init__.py +0 -0
  122. {bompage-1.2.2 → bompage-1.3.0}/tests/render/test_data.py +0 -0
  123. {bompage-1.2.2 → bompage-1.3.0}/tests/render/test_web.py +0 -0
  124. {bompage-1.2.2 → bompage-1.3.0}/tests/scripts/test_merge_sbom_licenses.py +0 -0
  125. {bompage-1.2.2 → bompage-1.3.0}/tests/test_bompage.py +0 -0
  126. {bompage-1.2.2 → bompage-1.3.0}/tests/test_dashboard_e2e.py +0 -0
  127. {bompage-1.2.2 → bompage-1.3.0}/tests/test_discovery.py +0 -0
  128. {bompage-1.2.2 → bompage-1.3.0}/tests/test_metadata.py +0 -0
  129. {bompage-1.2.2 → bompage-1.3.0}/tests/test_models.py +0 -0
  130. {bompage-1.2.2 → bompage-1.3.0}/tests/test_prune.py +0 -0
  131. {bompage-1.2.2 → bompage-1.3.0}/tests/test_time.py +0 -0
  132. {bompage-1.2.2 → bompage-1.3.0}/tsconfig.json +0 -0
  133. {bompage-1.2.2 → bompage-1.3.0}/uv.lock +0 -0
  134. {bompage-1.2.2 → bompage-1.3.0}/vite.config.ts +0 -0
  135. {bompage-1.2.2 → bompage-1.3.0}/vitest.config.ts +0 -0
  136. {bompage-1.2.2 → bompage-1.3.0}/web-src/bompage.png +0 -0
  137. {bompage-1.2.2 → bompage-1.3.0}/web-src/public/android-chrome-192x192.png +0 -0
  138. {bompage-1.2.2 → bompage-1.3.0}/web-src/public/android-chrome-512x512.png +0 -0
  139. {bompage-1.2.2 → bompage-1.3.0}/web-src/public/apple-touch-icon.png +0 -0
  140. {bompage-1.2.2 → bompage-1.3.0}/web-src/public/favicon-16x16.png +0 -0
  141. {bompage-1.2.2 → bompage-1.3.0}/web-src/public/favicon-32x32.png +0 -0
  142. {bompage-1.2.2 → bompage-1.3.0}/web-src/public/favicon.ico +0 -0
  143. {bompage-1.2.2 → bompage-1.3.0}/web-src/public/site.webmanifest +0 -0
  144. {bompage-1.2.2 → bompage-1.3.0}/web-src/theme.css +0 -0
@@ -31,4 +31,5 @@ wheels/
31
31
  /docs/superpowers/
32
32
  /styles/
33
33
  /Taskfile.d/
34
+ AGENTS.md
34
35
  CLAUDE.md
@@ -12,9 +12,9 @@ workflow:
12
12
  # semantic-release created the tag. On the tag pipeline only the release
13
13
  # jobs matter, and none of them gate on lint/test anyway (each has an
14
14
  # explicit `needs:`). DISABLE_JOBS switches off every op_so lint/test
15
- # job; the four jobs the release needs re-enable themselves below
16
- # (prepare-uv:sync, publish-pypi-uv, pages, push-sbom). build-web has no
17
- # DISABLE_JOBS guard, so it survives as-is.
15
+ # job; the jobs the release needs re-enable themselves below
16
+ # (prepare-uv:sync, publish-pypi-uv, pages, generate-sbom, bompage-push).
17
+ # build-web has no DISABLE_JOBS guard, so it survives as-is.
18
18
  DISABLE_JOBS: "true"
19
19
  - if: $CI_COMMIT_BRANCH
20
20
  # Superseded pipelines abandon their still-running interruptible jobs when a
@@ -45,11 +45,11 @@ variables:
45
45
  TASK_VERSION: "v3.53.1"
46
46
  IMAGE_UV: ghcr.io/astral-sh/uv:python${PYTHON_VERSION}-trixie-slim
47
47
  IMAGE_NODE: node:24-trixie-slim
48
- # Trivy generates bompage's own release SBOM in the `push-sbom` job. Pinned so
49
- # re-running an old tag is reproducible; Renovate bumps it (custom manager in
50
- # renovate.json).
48
+ # Trivy generates bompage's own release SBOM in the `generate-sbom` job. Pinned
49
+ # so re-running an old tag is reproducible; Renovate bumps it (custom manager
50
+ # in renovate.json).
51
51
  TRIVY_VERSION: "0.74.0"
52
- # Node binary installed directly in the `push-sbom` job (a Python-image job):
52
+ # Node binary installed directly in the `generate-sbom` job (a Python-image job):
53
53
  # Trivy's pnpm analyzer only reads license data from an installed
54
54
  # `node_modules`, not from `pnpm-lock.yaml` alone. Pinned like TRIVY_VERSION
55
55
  # above; Renovate bumps it (custom manager in renovate.json). pnpm itself
@@ -94,6 +94,26 @@ include:
94
94
  inputs:
95
95
  publish_pypi_uv_python_version: "3.14"
96
96
  publish_pypi_uv_stage: pypi-release
97
+ # Only the `bompage-push` job is used here: `bompage_pages_enabled`/
98
+ # `bompage_prune_enabled` are turned off because the component unconditionally
99
+ # also defines a `pages` job (this repo already has its own, for the mkdocs
100
+ # docs site -- a same-name collision) and a `bompage-prune` job (which would
101
+ # otherwise fire on this repo's own scheduled Renovate pipeline and try to
102
+ # prune "$CI_PROJECT_URL.git", the wrong repository). Both belong only to the
103
+ # central sbom-bompage repo's own inclusion (deploy/.gitlab-ci.yml).
104
+ #
105
+ # `bompage_push_repo` is a literal, not `${VARIABLE}`: like the
106
+ # `*_python_version` inputs above, `include: inputs:` cannot reference this
107
+ # file's own `variables:` block (see the note on `include:` above).
108
+ - component: gitlab.com/op_so/components/pages/bompage@v1.0.0
109
+ inputs:
110
+ bompage_push_stage: sbom-release
111
+ bompage_push_repo: "https://gitlab.com/op_so/pages/sbom-bompage.git"
112
+ bompage_push_component: bompage
113
+ bompage_push_sbom: sbom.cdx.json
114
+ bompage_push_sbom_format: cyclonedx
115
+ bompage_pages_enabled: false
116
+ bompage_prune_enabled: false
97
117
 
98
118
  stages:
99
119
  - prepare-uv
@@ -104,6 +124,10 @@ stages:
104
124
  - pypi-release
105
125
  - pages-release
106
126
  - sbom-release
127
+ # The bompage component always declares `bompage-prune` in this stage, even
128
+ # with `bompage_prune_enabled: false`; the stage must exist or the whole
129
+ # pipeline is rejected. The job itself never runs here.
130
+ - prune
107
131
  - renovate
108
132
 
109
133
  # Shared pnpm setup for the dashboard sources (web-src/). corepack reads the
@@ -239,7 +263,7 @@ test-e2e:
239
263
  rules: *skip-when-jobs-disabled
240
264
  before_script:
241
265
  # Pinned uv release tarball, same reasoning and shape as the Trivy / Node
242
- # installs in `push-sbom`: `curl … | sh` returns 0 when the download fails
266
+ # installs in `generate-sbom`: `curl … | sh` returns 0 when the download fails
243
267
  # (the installer just gets an empty script) and the job then dies later on a
244
268
  # confusing "uv: not found".
245
269
  - |
@@ -303,7 +327,7 @@ test-e2e:
303
327
  policy: pull
304
328
  interruptible: true
305
329
 
306
- # Required by publish-pypi-uv / pages / push-sbom, so it must survive the
330
+ # Required by publish-pypi-uv / pages / generate-sbom, so it must survive the
307
331
  # DISABLE_JOBS switch the tag pipeline sets in `workflow:` above (the component
308
332
  # ships `- if: $DISABLE_JOBS == "true" when: never` as its first rule).
309
333
  prepare-uv:sync:
@@ -400,47 +424,23 @@ pages:
400
424
  rules:
401
425
  - if: $CI_COMMIT_TAG
402
426
 
403
- # Dogfooding: on a release tag, generate bompage's own SBOM with Trivy and
404
- # publish it into the central SBOM repository via `bompage push` (spec sections 5
405
- # and 9). The released version is carried two ways: `uv lock` restamps it into
406
- # uv.lock so Trivy records it as the SBOM's root component version, and
407
- # `--sbom-version` puts it in the snapshot name
408
- # (reports/bompage/sbom-YYYYMMDD-<version>.json) and the commit message.
409
- # `push_report`'s anti-noise filter skips the commit when the dependency set is
410
- # byte-identical to the previous snapshot.
411
- #
412
- # Requires a masked CI/CD variable BOMPAGE_TOKEN: a project/group access token
413
- # scoped to write_repository on gitlab.com/op_so/pages/sbom-bompage only (spec
414
- # section 5.3). `bompage push` reads it from that variable.
415
- #
416
- # Signed (attestation spec section 6.3): a masked, protected CI/CD variable
417
- # BOMPAGE_SIGN_KEY on this project holds the private ed25519 key (see
418
- # deploy/README.md's "Attestation" section for how it was generated and how
419
- # its public half was added to `trust/allowed_signers` in the central
420
- # gitlab.com/op_so/pages/sbom-bompage repository). `bompage push` picks it up
421
- # on its own (`--sign-key` reads the BOMPAGE_SIGN_KEY env var by default, same
422
- # mechanism as BOMPAGE_TOKEN above) -- but it shells out to `ssh-keygen` to do
423
- # the signing (attest.py), so `openssh-client` must be installed below like
424
- # every other job in this file that touches git/signing.
425
- push-sbom:
427
+ # Dogfooding: on a release tag, generate bompage's own SBOM with Trivy (spec
428
+ # sections 5 and 9). The released version is carried two ways: `uv lock`
429
+ # restamps it into uv.lock so Trivy records it as the SBOM's root component
430
+ # version, and the `bompage-push` job below (from the
431
+ # gitlab.com/op_so/components/pages/bompage component) puts it in the snapshot
432
+ # name (reports/bompage/sbom-YYYYMMDD-<version>.json) and the commit message via
433
+ # its own `--sbom-version`. `push_report`'s anti-noise filter skips the commit
434
+ # when the dependency set is byte-identical to the previous snapshot.
435
+ generate-sbom:
426
436
  extends: .abstract-uv
427
437
  stage: sbom-release
428
- # Pushes to an external repository: not interruptible.
429
- interruptible: false
430
438
  needs:
431
439
  - job: "prepare-uv:sync"
432
440
  artifacts: true
433
- # Only publish the SBOM once the release itself has shipped to PyPI.
434
- # `optional` keeps non-tag pipelines (where neither job runs) valid.
435
- - job: publish-pypi-uv
436
- artifacts: false
437
- optional: true
438
- variables:
439
- # Central repository that hosts bompage's SBOM history.
440
- BOMPAGE_REPO: "https://gitlab.com/op_so/pages/sbom-bompage.git"
441
441
  before_script:
442
442
  - *install-task # curl + ca-certificates + task
443
- - DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends git xz-utils openssh-client
443
+ - DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends xz-utils
444
444
  # Pinned Trivy release tarball (no `curl | sh`): reproducible, fails loudly.
445
445
  # Asset suffix is Linux-64bit (amd64) or Linux-ARM64 depending on the runner.
446
446
  - |
@@ -484,7 +484,6 @@ push-sbom:
484
484
  - PNPM_VERSION="$(node -e "console.log(require('./package.json').packageManager.split('@')[1])")"
485
485
  - npm install --global "pnpm@${PNPM_VERSION}"
486
486
  - pnpm install --frozen-lockfile --prod
487
- - 'test -n "$BOMPAGE_TOKEN" || { echo "ERROR: CI/CD variable BOMPAGE_TOKEN is unset or empty" >&2; exit 1; }'
488
487
  # `.venv` (bompage installed editable) matches the committed 0.0.0 lock here;
489
488
  # stamp the real version afterwards and relock so uv.lock carries it before
490
489
  # Trivy reads it as the SBOM's root component version.
@@ -509,19 +508,38 @@ push-sbom:
509
508
  # `licenses` fields, by purl, for packages the `fs` scan above already kept.
510
509
  - trivy rootfs --quiet --format cyclonedx --scanners license --offline-scan --output sbom-rootfs.cdx.json .
511
510
  - uv run --no-sync python scripts/merge_sbom_licenses.py sbom-fs.cdx.json sbom-rootfs.cdx.json --output sbom.cdx.json
512
- - >
513
- uv run --no-sync bompage push
514
- --component bompage
515
- --sbom sbom.cdx.json
516
- --repo "$BOMPAGE_REPO"
517
- --branch main
518
- --sbom-version "${CI_COMMIT_TAG#v}"
519
- --sbom-format cyclonedx
520
- --source-project "$CI_PROJECT_URL"
521
- --source-commit "$CI_COMMIT_SHA"
522
- --source-ref "$CI_COMMIT_TAG"
523
- --ci-job-url "$CI_JOB_URL"
511
+ artifacts:
512
+ paths:
513
+ - sbom.cdx.json
514
+ expire_in: 1 day
524
515
  # No $DISABLE_JOBS guard: the tag pipeline sets DISABLE_JOBS to switch off
525
- # lint/test (see `workflow:`), and the SBOM push must still run.
516
+ # lint/test (see `workflow:`), and the SBOM generation must still run so
517
+ # `bompage-push` below has something to push.
518
+ rules:
519
+ - if: $CI_COMMIT_TAG
520
+
521
+ # `bompage-push` is defined by the gitlab.com/op_so/components/pages/bompage
522
+ # component included above. Its default `rules:` skip the job when
523
+ # $DISABLE_JOBS == "true" -- but that variable is set to "true" exactly on the
524
+ # tag pipeline (see `workflow:` at the top of this file), i.e. exactly when the
525
+ # push must run. Overridden here the same way `publish-pypi-uv`/`pages` already
526
+ # override the same component-supplied guard elsewhere in this file.
527
+ #
528
+ # Signing (attestation spec section 6.3): a masked, protected CI/CD variable
529
+ # BOMPAGE_SIGN_KEY on this project holds the private ed25519 key (see
530
+ # deploy/README.md's "Attestation" section for how it was generated and how
531
+ # its public half was added to `trust/allowed_signers` in the central
532
+ # gitlab.com/op_so/pages/sbom-bompage repository). `bompage push` picks it up
533
+ # on its own from the environment -- the component does not need to know about
534
+ # it, same as BOMPAGE_TOKEN.
535
+ bompage-push:
536
+ needs:
537
+ - job: generate-sbom
538
+ artifacts: true
539
+ # Only publish the SBOM once the release itself has shipped to PyPI.
540
+ # `optional` keeps non-tag pipelines (where neither job runs) valid.
541
+ - job: publish-pypi-uv
542
+ artifacts: false
543
+ optional: true
526
544
  rules:
527
545
  - if: $CI_COMMIT_TAG
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: bompage
3
- Version: 1.2.2
3
+ Version: 1.3.0
4
4
  Summary: A generic, server-less system to track SBOM history of N components and publish a static dashboard
5
5
  Project-URL: Homepage, https://gitlab.com/op_so/pages/bompage
6
6
  Project-URL: Documentation, https://op_so.gitlab.io/pages/bompage/
@@ -163,17 +163,19 @@ a signed attestation sidecar (`.att` / `.att.sig`) beside the SBOM, which
163
163
  changes &mdash; signing is entirely opt-in.
164
164
 
165
165
  On GitLab you usually do not call it by hand &mdash; include the ready-made
166
- component instead:
166
+ `bompage-push` job instead (signing, if `BOMPAGE_SIGN_KEY` is set in the
167
+ project, is picked up automatically &mdash; see `deploy/README.md`):
167
168
 
168
169
  ```yaml
170
+ stages:
171
+ - sbom-release # bompage_push_stage default; declare it or override the input
172
+
169
173
  include:
170
- - component: gitlab.com/op_so/pages/bompage/push-report@v1
174
+ - component: gitlab.com/op_so/components/pages/bompage@v1.0.0
171
175
  inputs:
172
- component: app-api
173
- sbom_path: build/sbom.cdx.json
174
- bompage_repo: https://gitlab.com/acme/bompage.git
175
- bompage_version: "1.2.3"
176
- bompage_sign_key_var: BOMPAGE_SIGN_KEY # optional, see deploy/README.md
176
+ bompage_push_repo: https://gitlab.com/acme/bompage.git
177
+ bompage_push_component: app-api
178
+ bompage_push_sbom: build/sbom.cdx.json
177
179
  ```
178
180
 
179
181
  ### `bompage prune` &mdash; keep the repository small
@@ -134,17 +134,19 @@ a signed attestation sidecar (`.att` / `.att.sig`) beside the SBOM, which
134
134
  changes &mdash; signing is entirely opt-in.
135
135
 
136
136
  On GitLab you usually do not call it by hand &mdash; include the ready-made
137
- component instead:
137
+ `bompage-push` job instead (signing, if `BOMPAGE_SIGN_KEY` is set in the
138
+ project, is picked up automatically &mdash; see `deploy/README.md`):
138
139
 
139
140
  ```yaml
141
+ stages:
142
+ - sbom-release # bompage_push_stage default; declare it or override the input
143
+
140
144
  include:
141
- - component: gitlab.com/op_so/pages/bompage/push-report@v1
145
+ - component: gitlab.com/op_so/components/pages/bompage@v1.0.0
142
146
  inputs:
143
- component: app-api
144
- sbom_path: build/sbom.cdx.json
145
- bompage_repo: https://gitlab.com/acme/bompage.git
146
- bompage_version: "1.2.3"
147
- bompage_sign_key_var: BOMPAGE_SIGN_KEY # optional, see deploy/README.md
147
+ bompage_push_repo: https://gitlab.com/acme/bompage.git
148
+ bompage_push_component: app-api
149
+ bompage_push_sbom: build/sbom.cdx.json
148
150
  ```
149
151
 
150
152
  ### `bompage prune` &mdash; keep the repository small
@@ -0,0 +1,55 @@
1
+ ---
2
+ # CI for the CENTRAL bompage repository (not the Python package repo).
3
+ #
4
+ # - On a push to the default branch: rebuild and republish the static dashboard
5
+ # (spec-bompage.md sections 3 and 8).
6
+ # - On a *scheduled* pipeline: run `bompage prune` to drop SBOM snapshots older
7
+ # than the retention depth (spec section 5.4). Its commit lands on the default
8
+ # branch and triggers a fresh pipeline whose `pages` job republishes.
9
+ #
10
+ # Both jobs come from the gitlab.com/op_so/components/pages/bompage CI/CD
11
+ # component (its image bundles the bompage CLI, git and ssh-keygen -- no more
12
+ # per-job `pip install bompage==...`).
13
+ #
14
+ # CI/CD variables:
15
+ # BOMPAGE_TOKEN required by both jobs; scoped token with write_repository on
16
+ # THIS repository. Must be a project/group access token or PAT
17
+ # -- the prune commit is pushed with it, and only a real token
18
+ # (not CI_JOB_TOKEN) makes that push start the follow-up
19
+ # pipeline whose `pages` job republishes the dashboard.
20
+
21
+ # Interruptible by default so superseded pipelines free their runners; the prune
22
+ # job opts out because it commits and pushes. Retries cover only causes that are
23
+ # never the code itself: infra hiccups plus transient GitLab API failures.
24
+ default:
25
+ interruptible: true
26
+ retry:
27
+ max: 2
28
+ when:
29
+ - api_failure
30
+ - runner_system_failure
31
+ - stuck_or_timeout_failure
32
+ - scheduler_failure
33
+
34
+ include:
35
+ - component: gitlab.com/op_so/components/pages/bompage@v1.0.0
36
+ inputs:
37
+ # `--metadata` rows fill the dashboard header. A value that is an http(s)
38
+ # URL is rendered as a link, so point readers at where the data comes
39
+ # from: this repository, the pipeline that built the page, and the tool
40
+ # itself. Drop or replace any of them -- they are an example, not a
41
+ # requirement. `--allowed-signers` is this repository's trust root
42
+ # (attestation spec section 5); passed explicitly even though it matches
43
+ # the default (`<reports>/../trust/allowed_signers`) so a reader does not
44
+ # have to know that default to see where verification gets its keys
45
+ # from. An empty or placeholder trust/allowed_signers (see that file)
46
+ # simply makes every verdict read `unknown` -- it never fails the build.
47
+ bompage_pages_metadata: >-
48
+ --allowed-signers trust/allowed_signers
49
+ --metadata "Repository=$CI_PROJECT_URL"
50
+ --metadata "Pipeline=$CI_PIPELINE_URL"
51
+ --metadata "Built with=https://gitlab.com/op_so/pages/bompage"
52
+
53
+ stages:
54
+ - prune
55
+ - pages
@@ -17,7 +17,7 @@ trust/
17
17
  allowed_signers # trust root for SBOM attestation verification (optional, see below)
18
18
  web/ # optional: a full built page (index.html + assets/) that
19
19
  # replaces the packaged dashboard, via `bompage build --web-dir web`
20
- .gitlab-ci.yml # pages job: install bompage, run `bompage build`, publish public/
20
+ .gitlab-ci.yml # includes the bompage CI/CD component: pages + prune jobs
21
21
  ```
22
22
 
23
23
  The dashboard page carries **no project data**: `bompage build` writes the
@@ -48,22 +48,28 @@ component's; reusing one is refused rather than shown twice.
48
48
 
49
49
  ## Ingesting SBOMs
50
50
 
51
- Each source pipeline pushes with the `push-report` GitLab CI/CD component
52
- published from `gitlab.com/op_so/pages/bompage`:
51
+ Each source pipeline pushes with the `bompage-push` job from the
52
+ `gitlab.com/op_so/components/pages/bompage` GitLab CI/CD component:
53
53
 
54
54
  ```yaml
55
+ stages:
56
+ - sbom-release # bompage_push_stage default; declare it or override the input
57
+
55
58
  include:
56
- - component: gitlab.com/op_so/pages/bompage/push-report@v1
59
+ - component: gitlab.com/op_so/components/pages/bompage@v1.0.0
57
60
  inputs:
58
- component: my-service
59
- sbom_path: artifacts/sbom.spdx.json
60
- bompage_repo: https://gitlab.com/acme/bompage.git # this repo
61
- bompage_version: "1.2.3" # pin the release
61
+ bompage_push_repo: https://gitlab.com/acme/bompage.git # this repo
62
+ bompage_push_component: my-service
63
+ bompage_push_sbom: artifacts/sbom.spdx.json
64
+ bompage_push_sbom_format: spdx
62
65
  ```
63
66
 
64
- The job `pip install`s the pinned `bompage` and runs `bompage push`: it clones
65
- this repo with the scoped token in `$BOMPAGE_TOKEN`, writes
66
- `reports/my-service/sbom-YYYYMMDD.json` and commits only if the content changed.
67
+ The job's image bundles a pinned `bompage` release and runs `bompage push`: it
68
+ clones this repo with the scoped token in `$BOMPAGE_TOKEN`, writes
69
+ `reports/my-service/sbom-YYYYMMDD.json` and commits only if the content
70
+ changed. By default it runs only on the tag pipeline, versioning the snapshot
71
+ from the tag; set `bompage_push_on: branch` to instead run from the consumer's
72
+ default branch, versioned `0.0.0-$CI_COMMIT_SHORT_SHA`.
67
73
 
68
74
  ## Attestation (optional signing)
69
75
 
@@ -88,8 +94,10 @@ this section for a deployment that does not need it, nothing else changes.
88
94
  ```
89
95
 
90
96
  3. Store the **private** key (`bompage-push`, no extension — never the `.pub`
91
- file) as a **masked, protected** CI/CD variable, e.g. `BOMPAGE_SIGN_KEY`, in
92
- every source project that should sign its pushes.
97
+ file) as a **masked, protected** CI/CD variable named exactly
98
+ `BOMPAGE_SIGN_KEY`, in every source project that should sign its pushes.
99
+ `bompage push` reads that variable from the job environment on its own —
100
+ the `bompage-push` component job needs no extra input to enable signing.
93
101
 
94
102
  Because GitLab CI masked variables strictly forbid whitespace and newlines,
95
103
  encode the private key as a single-line Base64 string before saving it
@@ -98,21 +106,9 @@ this section for a deployment that does not need it, nothing else changes.
98
106
  - **Linux:** `base64 -w 0 bompage-push`
99
107
  - **macOS:** `base64 -i bompage-push | tr -d '\n'`
100
108
 
101
- 4. Point each source pipeline's `push-report` include at it:
102
-
103
- ```yaml
104
- include:
105
- - component: gitlab.com/op_so/pages/bompage/push-report@v1
106
- inputs:
107
- component: my-service
108
- sbom_path: artifacts/sbom.spdx.json
109
- bompage_repo: https://gitlab.com/acme/bompage.git
110
- bompage_version: "1.2.3"
111
- bompage_sign_key_var: BOMPAGE_SIGN_KEY
112
- ```
113
-
114
- A source pipeline that omits `bompage_sign_key_var` keeps pushing unsigned —
115
- its snapshots simply verify as `unknown` here, never as a build failure.
109
+ A source pipeline whose project never sets `BOMPAGE_SIGN_KEY` keeps pushing
110
+ unsigned — its snapshots simply verify as `unknown` here, never as a build
111
+ failure.
116
112
 
117
113
  ### Protecting `trust/`
118
114
 
@@ -175,8 +171,8 @@ bompage prune --repo <url> --token <token> --dry-run
175
171
  lexicographically. Every source pipeline must respect this, including those
176
172
  driven by a third-party provisioning tool.
177
173
  - Source pipelines push with a **scoped token** limited to this repository, and
178
- commit only when the SBOM content actually changed — the `push-report`
179
- component centralises this (anti-noise filter, spec sections 5.1 / 5.2 / 10).
174
+ commit only when the SBOM content actually changed — the `bompage-push`
175
+ component job centralises this (anti-noise filter, spec sections 5.1 / 5.2 / 10).
180
176
  - Hosts provisioned via IaC generate their SBOM on the target, fetch it back to
181
177
  the controller/runner, and the **controller** commits and pushes — never the
182
178
  host itself (spec section 3).
@@ -16,9 +16,11 @@
16
16
  # 1. ssh-keygen -q -t ed25519 -N '' -C bompage-push -f bompage-push
17
17
  # 2. Add the line above with the contents of bompage-push.pub.
18
18
  # 3. Store the PRIVATE key (bompage-push, no extension) as a masked,
19
- # protected CI/CD variable (e.g. BOMPAGE_SIGN_KEY) -- never commit it.
20
- # 4. Wire that variable name into push-report.yml's `bompage_sign_key_var`
21
- # input from every source pipeline.
19
+ # protected CI/CD variable named exactly BOMPAGE_SIGN_KEY, in every
20
+ # source pipeline that should sign its pushes -- never commit it.
21
+ # `bompage push` (the `bompage-push` job of the
22
+ # gitlab.com/op_so/components/pages/bompage component) reads that
23
+ # variable from the environment on its own; no extra wiring needed.
22
24
  #
23
25
  # This file is the REAL trust root -- unlike its read-only copy the published
24
26
  # site may serve for convenience, it lives in the central repository and is
@@ -1,6 +1,6 @@
1
1
  # Overview
2
2
 
3
- Version: 1.2.2
3
+ Version: 1.3.0
4
4
 
5
5
  A generic, server-less system to centralise the SBOM history of **N components**
6
6
  in a dedicated Git repository and publish a static
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "bompage"
3
- version = "1.2.2"
3
+ version = "1.3.0"
4
4
  description = "A generic, server-less system to track SBOM history of N components and publish a static dashboard"
5
5
  authors = [
6
6
  { name = "FX Soubirou", email = "soubirou@yahoo.fr" }
@@ -91,9 +91,9 @@ show_missing = true
91
91
  fail_under = 100
92
92
 
93
93
  [tool.coverage.run]
94
- # `scripts/merge_sbom_licenses.py` runs in the release pipeline (the `push-sbom`
95
- # job), so it is held to the same bar as `src/`. The demo-data generator is a
96
- # local convenience with no CI role and is left out.
94
+ # `scripts/merge_sbom_licenses.py` runs in the release pipeline (the
95
+ # `generate-sbom` job), so it is held to the same bar as `src/`. The demo-data
96
+ # generator is a local convenience with no CI role and is left out.
97
97
  source = ["src", "scripts"]
98
98
  omit = ["scripts/gen-demo-data.py"]
99
99
 
@@ -10,7 +10,7 @@
10
10
  "customManagers": [
11
11
  {
12
12
  "customType": "regex",
13
- "description": "Trivy version pinned in .gitlab-ci.yml (push-sbom job)",
13
+ "description": "Trivy version pinned in .gitlab-ci.yml (generate-sbom job)",
14
14
  "managerFilePatterns": ["/^\\.gitlab-ci\\.yml$/"],
15
15
  "matchStrings": ["TRIVY_VERSION:\\s*\"(?<currentValue>[^\"]+)\""],
16
16
  "datasourceTemplate": "github-releases",
@@ -19,7 +19,7 @@
19
19
  },
20
20
  {
21
21
  "customType": "regex",
22
- "description": "Node version pinned in .gitlab-ci.yml (push-sbom job)",
22
+ "description": "Node version pinned in .gitlab-ci.yml (generate-sbom job)",
23
23
  "managerFilePatterns": ["/^\\.gitlab-ci\\.yml$/"],
24
24
  "matchStrings": ["NODE_VERSION:\\s*\"(?<currentValue>[^\"]+)\""],
25
25
  "datasourceTemplate": "github-tags",
@@ -0,0 +1,17 @@
1
+ """Path-segment safety shared by every module that turns untrusted input into
2
+ a file name under ``reports/<component>/``.
3
+
4
+ ``push`` validates ``component`` / ``sbom_version`` before writing there
5
+ (spec §4: one directory = one component); ``attest`` validates an
6
+ attestation's own ``prev.file`` before joining it onto the same tree
7
+ (SECURITY.md: an input that escapes ``reports/<component>/`` is a
8
+ vulnerability regardless of what leaks). Both need the identical rule --
9
+ reject separators and ``.`` / ``..`` -- so it lives here once rather than as
10
+ two copies that could silently drift apart.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+
17
+ SAFE_SEGMENT = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*")
@@ -12,6 +12,7 @@ from __future__ import annotations
12
12
 
13
13
  from datetime import date
14
14
 
15
+ from bompage.analysis.timeline import highest_by_name
15
16
  from bompage.models import ComponentReport, Drift, PackageOccurrence
16
17
 
17
18
  # A drift needs the package on at least this many components, at this many
@@ -25,22 +26,22 @@ def build_package_index(
25
26
  """Map each package name to one occurrence per component that carries it.
26
27
 
27
28
  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.
29
+ speaks of "the version detected on each", singular); keep the highest --
30
+ via the same tie-break :func:`bompage.analysis.timeline.highest_by_name`
31
+ applies -- so a within-component duplicate is not mistaken for a
32
+ cross-component drift, and the drift list can never disagree with the
33
+ timeline about which version a component is "really" on.
30
34
  """
31
35
  index: dict[str, list[PackageOccurrence]] = {}
32
36
  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(
37
+ for name, pkg in highest_by_name(component.packages).items():
38
+ index.setdefault(name, []).append(
39
+ PackageOccurrence(
38
40
  component=component.name,
39
41
  version=pkg.version,
40
42
  ecosystem=pkg.ecosystem,
41
43
  )
42
- for name, occurrence in best.items():
43
- index.setdefault(name, []).append(occurrence)
44
+ )
44
45
  return index
45
46
 
46
47
 
@@ -70,11 +70,23 @@ def _is_strong_copyleft(license_id: str | None) -> bool:
70
70
 
71
71
 
72
72
  def build_inventory(packages: list[Package]) -> list[Package]:
73
- """Return the deduplicated, ecosystem-enriched inventory, sorted by name."""
73
+ """Return the deduplicated, ecosystem-enriched inventory, sorted by name.
74
+
75
+ A duplicate (name, version, purl) is merged, not dropped: one occurrence
76
+ of the same package can legitimately carry a license the other lacks
77
+ (e.g. a manifest with no license metadata vs. a lockfile-resolved entry
78
+ for the exact same package), and keeping only whichever happened to be
79
+ seen first would silently lose that license -- and with it, a copyleft
80
+ flag the dashboard depends on.
81
+ """
74
82
  seen: dict[tuple[str, str | None, str | None], Package] = {}
75
83
  for pkg in packages:
76
84
  key = (pkg.name, pkg.version, pkg.purl)
77
- if key in seen:
85
+ existing = seen.get(key)
86
+ if existing is not None and (existing.license or not pkg.license):
87
+ # Already captured a license (skip a duplicate lacking one), or
88
+ # this duplicate has none either (nothing would change) -- either
89
+ # way, no need to re-enrich and overwrite the entry already held.
78
90
  continue
79
91
  seen[key] = pkg.model_copy(
80
92
  update={
@@ -24,7 +24,7 @@ from bompage.models import Change, Package, Snapshot, TimelineEntry
24
24
  _MIN_SNAPSHOTS_FOR_DIFF = 2
25
25
 
26
26
 
27
- def _highest_by_name(packages: list[Package]) -> dict[str, Package]:
27
+ def highest_by_name(packages: list[Package]) -> dict[str, Package]:
28
28
  """One representative package per name: the highest ``version or ""``.
29
29
 
30
30
  A name may legitimately appear more than once in one snapshot (e.g. an npm
@@ -34,7 +34,9 @@ def _highest_by_name(packages: list[Package]) -> dict[str, Package]:
34
34
  ``diffSnapshots`` apply -- or the timeline, the deltas and the drift list
35
35
  quietly disagree about which version a component is on. Building the map by
36
36
  plain assignment would instead be last-wins, i.e. dependent on the order the
37
- scanner happened to emit.
37
+ scanner happened to emit. Public (not `_`-prefixed): shared with
38
+ ``drift.build_package_index``, which applies the exact same tie-break to a
39
+ component's packages -- see that module's docstring.
38
40
  """
39
41
  best: dict[str, Package] = {}
40
42
  for pkg in packages:
@@ -49,10 +51,10 @@ def snapshot_versions(snapshot: Snapshot) -> dict[str, str | None]:
49
51
 
50
52
  Keeps the highest version of a name listed more than once, so the timeline
51
53
  and the added/removed deltas stay deterministic instead of depending on
52
- package order (see :func:`_highest_by_name`).
54
+ package order (see :func:`highest_by_name`).
53
55
  """
54
56
  return {
55
- name: pkg.version for name, pkg in _highest_by_name(snapshot.packages).items()
57
+ name: pkg.version for name, pkg in highest_by_name(snapshot.packages).items()
56
58
  }
57
59
 
58
60
 
@@ -99,7 +101,7 @@ def diff_last_two(
99
101
  return [], []
100
102
  per_snapshot = _resolve(snapshots, versions)
101
103
  previous = per_snapshot[-2]
102
- current = _highest_by_name(snapshots[-1].packages)
104
+ current = highest_by_name(snapshots[-1].packages)
103
105
  added = [pkg for name, pkg in current.items() if name not in previous]
104
106
  removed = [name for name in previous if name not in current]
105
107
  return added, sorted(removed)