Skip to content

build: publish a contents SBOM and license evidence with every wheel - #1174

Merged
aristarkhovNV merged 104 commits into
NVIDIA:mainfrom
aristarkhovNV:sbom-upstream
Oct 22, 2026
Merged

aristarkhovNV merged 104 commits into
NVIDIA:mainfrom
aristarkhovNV:sbom-upstream

Conversation

@aristarkhovNV

@aristarkhovNV aristarkhovNV commented Oct 2, 2026 •

Copy link
Copy Markdown
Collaborator

Every published isaaccapture wheel gains a machine-readable inventory of what
it redistributes, plus the license text behind each obligation — readable without
installing the package, running it, or contacting a hosted service.

isaaccapture-<version>-<abi>-<platform>.whl
└── isaaccapture-<version>.dist-info/
    ├── METADATA                       # License-Expression + one License-File per text below
    ├── licenses/
    │   ├── LICENSE.md                 # this project's own license
    │   ├── LICENSES/*.txt             # texts the SPDX headers on our sources name
    │   ├── THIRD-PARTY-NOTICES.md     # component -> license -> text, with roles
    │   └── third-party/<component>/   # its own file verbatim, or a reference text, labelled
    └── sboms/
        └── <wheel filename>.spdx.json # SPDX 2.3 contents inventory

.dist-info/sboms/ is the location PEP 770
reserves, and it is shared: auditwheel writes its own CycloneDX document there
for the libraries it vendored. The collector finds its document by name and
leaves anything else alone.

Where the facts come from

There is no checked-in dependency registry, and adding one would be a second
source of truth.
The inventory is derived from the build that produced the
wheel — the CMake file API codemodel, the sources FetchContent actually fetched,
the ELF headers of what was linked. Declare a dependency the way you already
would and it appears in the next wheel's inventory.

Two things fail the Release build rather than shipping a gap, and neither is
fixed by excluding a file or writing down an assumption:

  • a wheel member the build cannot explain — teach the collector that route;
  • a redistributed component with no license text — obtain the terms.

What runs in CI

Four steps in the Release matrix of build-ubuntu package the evidence into each
wheel, check it, install the finished wheel and re-check it exactly as the
published consumer instructions do, then upload sidecars. A new
publish-sbom-evidence job attaches the assembled set to the GitHub release,
because Actions artifacts expire in days and a tagged release is where a consumer
is told to look.

Conformance is third-party, not self-asserted: every document is run through
pyspdxtools.

Verification

  • 110 collector tests, plus 34 SPDX-conformance cases, against a synthetic
    project built for the purpose (tests/python/sbom/synth.py).
  • Rehearsed end to end on a fork, including a real release tag: the publish
    job attached 40 assets and a manifest covering 7 wheels × 4 sidecars. Several
    commits here are fixes that rehearsal found — a universal wheel whose document
    differed by which matrix entry built it, and an evidence-assembly step that
    collided on that wheel's sidecar name.
  • Checked against output, not just the suite: both wheels regenerated from a
    real build tree, isaaccapture-sbom check OK on each, pyspdxtools clean.

Notes for review

  • CMakeLists.txt gains a feature-tested cmake_file_api() query rather than a
    raised minimum. cmake_file_api() arrived in 3.27, and raising the project
    minimum would opt into every policy up to it — including CMP0148, which removes
    the FindPythonInterp module that pybind11's legacy path still reaches through
    vcpkg's find_package wrapper. Where the command is absent no codemodel is
    written and the collector says so itself.
  • The collector is its own project (scripts/sbom/pyproject.toml), so its pins
    live there and nowhere else — never restate them as --with flags.
  • deps/third_party/openxr_extensions/ is a pure move of the two non-NVIDIA
    OpenXR extension headers out from under deps/cloudxr/, so their origin and
    license are separable from NVIDIA's.
  • The 73 commits are structured for review one at a time; the squash-merge will
    collapse them regardless.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Release wheels now include an SPDX software bill of materials, license evidence, build evidence, and license reports.
    • Release downloads include supporting evidence files and a license summary. Wheels and their evidence are checked for completeness and integrity before publishing.
    • Documentation explains the contents of the SBOM and how to verify release evidence.
  • Improvements

    • Wheels now include project license files. Building the project requires CMake 3.27 or later.

Pins spdx/license-list-data at commit 31ba1a50 (v3.29.0), so that an SBOM
collector can name a dependency's license by matching the text that
dependency ships against the SPDX reference texts. Nothing from the
checkout is redistributed, and it builds nothing.

Upstream publishes every license in several formats, so the download
command clones with --filter=tree:0, sparse-checks out json/, and detaches
at the pinned commit: 27 MB instead of 405 MB, and a no-op on reconfigure
once that commit is already checked out.

Pinned by commit rather than by tag, so nothing upstream can move
underneath a build.

Signed-off-by: Andrei Aristarkhov <aaristarkhov@nvidia.com>
deps/cloudxr/openxr_extensions held seven headers, two of them written by
other people: openxr_extension_helpers.h (The Khronos Group, Apache-2.0) and
XR_MNDX_xdev_space.h (Collabora, BSL-1.0). Both compile into shipped
extension modules, so this project redistributes them under those terms,
and nothing in the layout said so.

Move the two into deps/third_party/openxr_extensions behind its own
interface target, which Teleop::openxr_extensions carries -- so every
consumer keeps linking one target and every #include is unchanged.

No header is modified. The moves are pure renames, and the NVIDIA headers
keep "openxr_extension_helpers.h" in quotes: a quoted include falls back to
the include path once the file is no longer beside them. Those headers also
ship verbatim inside the CloudXR SDK tarball, so editing them here would
make our copies diverge from upstream's.

Signed-off-by: Andrei Aristarkhov <aaristarkhov@nvidia.com>
pybind11-stubgen wrote .pyi straight into the staging tree, which left them
as the only files in the wheel with no build-tree origin. Every other staged
file has one -- authored .py resolve to src/python/isaaccapture, extension
modules to their CMake link output, _generated_tracker_exports.py to
build/generated/trackers -- because staging is a copy destination rather than
somewhere a build step writes for the first time.

Generate into build/generated/stubs/$<CONFIG> and copy from there, matching
what stage_generated_tracker_exports already does. generate_stubs.py now takes
the import path and the output path separately: it still imports the module
from the staged tree, since that is where the extension modules are, but no
longer writes back into it.

Signed-off-by: Andrei Aristarkhov <aaristarkhov@nvidia.com>
Each published wheel gains an SPDX 2.3 inventory of what it redistributes under
`.dist-info/sboms/`, the license text behind every obligation under
`.dist-info/licenses/`, and sidecar reports attached to tagged releases.

The inventory is discovered, not declared. `scripts/sbom` reads what the build
left behind -- CMake's file API codemodel, the `_deps/*-src` checkouts, the
archives under `deps/`, the working tree -- so a dependency that builds is a
dependency that appears, and there is no registry to keep in step with
`CMakeLists.txt`. Licenses are named by matching each component's own text
against `spdx/license-list-data`, which `deps/third_party` fetches at a pinned
commit; an unmatched text becomes a `LicenseRef-` carrying the text verbatim
rather than a guess.

Two conditions fail the Release build instead of shipping a gap: a wheel member
the build cannot explain, and a redistributed component with no license text.

Requires CMake 3.27 for `cmake_file_api()`, which lets the codemodel query live
in `CMakeLists.txt` rather than in whatever invoked cmake. Reading a generator's
own output instead would only work under Makefiles -- Ninja writes no
`link.txt`.

The collector is a project of its own so its pins live in one place, and is run
as `uv run --project scripts/sbom isaaccapture-sbom ...`. Formats it would
otherwise parse by hand come from the packages that own them: installer,
packaging, license-expression, packageurl-python, pyelftools.

Consumer-facing detail is in docs/source/references/sbom.rst.

Signed-off-by: Andrei Aristarkhov <aaristarkhov@nvidia.com>
Running the collector against a full build rather than the synthetic fixtures
turned up four routes it could not explain and two claims it got wrong.

auditwheel writes its own PEP 770 document to `.dist-info/sboms/`, so the
re-run guard and the verifier now look for this collector's document by name
instead of treating the directory as its own.

A repair tool rewrites SONAME and RPATH, so a vendored library no longer hashes
to the bytes it came from. The GNU build-id does not change, and ties a patched
member back to an archive member, a build output, or the build machine's copy.

`_deps` holds both fetched sources and this build's own output. Only `*-src` is
the component-source domain; the rest is indexed as generated, and a tree a
dependency stages there takes the component its byte-identical siblings name.

RECORD states size 0 for an empty member, which `or ""` erased -- every wheel
carries an empty `py.typed`, so every wheel failed its own check. And zero bytes
are identical everywhere, so an empty file is attributed by path rather than by
a content match that proves nothing.

A licence id newer than the grammar `license-expression` ships cannot be
validated by a consumer, so the text travels as a LicenseRef instead.

Signed-off-by: Andrei Aristarkhov <aaristarkhov@nvidia.com>
Indexing the machine's libraries by build-id read each one whole: 19s and 2.4GB
of resident memory, because libcuda and friends are hundreds of megabytes.
pyelftools seeks, so only the headers and the note need to be read -- 4s and
30MB.

Empty files are excluded from the staged-tree ownership inference for the same
reason they are excluded from attribution: a CMake scaffolding directory full of
stamp files would otherwise adopt whichever component shipped an empty file.
That alone was claiming 22 directories; three remain, all correct.

A file the build transformed out of a component's tree is a derivative work
carrying that component's terms, so it is labelled derived rather than
generated.

Signed-off-by: Andrei Aristarkhov <aaristarkhov@nvidia.com>
RECORD hashes every member including this document, and the document is
rendered before RECORD is regenerated, so listing either is a fixed point no
hash function has. SPDX 2.3 requires a checksum on every file listed, so they
are omitted rather than listed without one.

A reader diffing the file list against the wheel finds exactly two gaps and
nothing explaining them.

Signed-off-by: Andrei Aristarkhov <aaristarkhov@nvidia.com>
Only the build directory in use was skipped when indexing the repository, so a
second one -- a developer's `build-debug`, or the tree scikit-build-core makes
-- was walked as if it were source. Its staged copies made a file's origin
ambiguous, which surfaces as a member that reached the wheel by an unknown
route: a confusing error pointing at the wrong thing.

A configured tree says what it is with a CMakeCache.txt, which is what is
skipped now rather than a list of names.

Signed-off-by: Andrei Aristarkhov <aaristarkhov@nvidia.com>
A system library's supplier read `Organization: libbsd0` -- the package name in
a slot meant for whoever provided it. dpkg records a maintainer, which is the
answer, formatted the way SPDX asks for it.

A declared dependency's SPDXID was slugified from the whole requirement, so
`websockets>=14.0` became `websockets-14.0` and read as a pinned version beside
a versionInfo this document deliberately leaves unasserted. The same name
recurs under different extras, so uniqueness comes from a digest of the
requirement; its text was always in the comment.

Signed-off-by: Andrei Aristarkhov <aaristarkhov@nvidia.com>
… a range

The CloudXR tarballs are downloaded at configure time, not checked in, so the
only evidence of their origin was a shell variable that stopped existing when
the script exited. The fetch script now writes the URL it used beside the
tarball, and the collector reads it: download location, and the host that
served it as supplier.

A `Requires-Dist` constraint is a range, which purl cannot express -- its
version field holds one exact version. VERS can, so the specifier is carried as
a structured external reference rather than only in a sentence, and a
requirement gated on an extra is an OPTIONAL_DEPENDENCY_OF rather than a flat
dependency.

A name match that a build-id contradicts is refused. Two SDKs ship a library
called libcloudxr.so; claiming one is the other would hand it the wrong
component's licence.

Signed-off-by: Andrei Aristarkhov <aaristarkhov@nvidia.com>
Audited every handler and default in the collector, keeping the ones whose
fallback degrades into a failure that is already loud and removing the rest.

Gone: two `except ValueError` guards around `relative_to`, unreachable because
the walk yields only files beneath its root; a second implementation of the
build-id reader carrying its own handlers; and the swallow that let an archive
this build fetched fail to open and reappear as every file it should have
explained being unattributable.

Narrowed: `except (OSError, Exception)` caught everything including bugs in this
parser, and is now the ELF errors it meant.

Made loud: a missing CMAKE_BUILD_TYPE silently became Release, pointing the
build graph and the staged-tree probe at a directory that need not exist.

Archives inside a build tree were indexed as if held in the repository,
contradicting the docstring; the two call sites now share one list of what is
not source.

Signed-off-by: Andrei Aristarkhov <aaristarkhov@nvidia.com>
A stock Apache-2.0 text is the same file in every project that ships one, and a
module that is nothing but an SPDX header is the same in every project using
that header. Resolving by content hash without a uniqueness check named
whichever candidate was indexed first, as a flat assertion.

Three claims were wrong: MUJOCO_LICENSE was a copy of flatbuffers, a first-party
module was a copy of a dependency, and seven CloudXR files came from the other
SDK. The first leaked into THIRD-PARTY-NOTICES.md, filing mujoco's
redistribution obligation under flatbuffers.

Content may now name a component only when the digest resolves to exactly one
-- this repository counting as a candidate, which is what distinguishes a
first-party file. Otherwise the path decides, and failing that the document says
the bytes are shared and claims no single source.

The gaps list keyed on whether anything matched, so a component whose match
could not be expressed carried a LicenseRef without being reported. It keys on
the conclusion now, and the warning states which of the two reasons applies.

A VERSION member with two lines put a newline inside versionInfo, which breaks
every tag-value serialisation and table rendering downstream.

Signed-off-by: Andrei Aristarkhov <aaristarkhov@nvidia.com>
`LICENSES/` is a dictionary of licence identifiers, not a statement about who
owns what: REUSE expects each file to hold the licence as published, and
`reuse download` fetches it with its placeholders intact.

Two had been personalised. Apache-2.0's instructional appendix was filled in
with this project's name, and MIT's `Copyright (c) <year> <copyright holders>`
line -- which is the whole of its attribution requirement -- had been deleted.
Both now normalise equal to the pinned SPDX list.

The grant was never modified; only the boilerplate around it. It mattered
because the collector was packaging these as third-party components' licence
texts, so the only notice shipped for another company's code was this project's.

Signed-off-by: Andrei Aristarkhov <aaristarkhov@nvidia.com>
A vendored component states an identifier in its headers and ships no licence
file, so something has to supply the text. It took this repository's REUSE pool,
because a file named after the identifier was already on disk there -- and the
pinned SPDX corpus, which is the authoritative copy, was discarded at load time
in favour of the n-grams used for matching.

The corpus keeps its texts now and supplies them. The pool licenses this
project and carries its notices; packaging one as a third party's terms put this
project's copyright above their code.

That leaves the reference text's placeholder where a holder belongs, so the
copyright line the headers state is recorded rather than dropped -- it is
already parsed, since naming the component is what it is parsed for. Declaring
no copyright beside a sourceInfo reading "states its own copyright" was the same
document contradicting itself.

The reference says why these rules exist rather than only what they are, and
covers the component that has no licence file of its own.

Signed-off-by: Andrei Aristarkhov <aaristarkhov@nvidia.com>
`python -m coverage` writes `.coverage` into whichever directory the suite ran
in, so measuring a test leaf leaves one behind under `tests/`. Nothing ignored
it and one was committed.

Signed-off-by: Andrei Aristarkhov <aaristarkhov@nvidia.com>
A second audit of a real wheel found the origin layer citing things that are
not sources.

Repository files were indexed by walking the working tree, skipping only
directories holding a CMakeCache.txt. An install prefix carries no such marker,
so two of them were walked as source and offered as candidate origins -- one
belonging to a different build than the document describes. Git knows which
files this repository holds, so that is what is indexed now.

A dependency's commit came from `git -C <dir> rev-parse HEAD`, which answers for
whichever repository encloses the directory. A checkout carrying no .git of its
own was therefore stamped with this project's commit. The directory has to be
the root of the checkout it is credited to.

Build-tree citations were written as `build/...` whatever the directory was
called, so following one resolved to the wrong tree or to nothing. They name the
directory that was read.

Bytes several components hold are not evidence the wheel contains any of their
files: `shared-content` no longer earns the component a CONTAINS edge, which the
wheel was claiming for flatbuffers on the strength of one stock Apache-2.0 text.
Such a file has no single source and so no primary, which kept it out of the
relationship graph entirely -- it is now tied to each candidate as OTHER, rather
than stating the ambiguity only in prose no tool reads.

Signed-off-by: Andrei Aristarkhov <aaristarkhov@nvidia.com>
Both audits of the regenerated wheel landed on the same class of defect: fields
stating more, or less, than what was read.

`licenseDeclared` echoed the conclusion for every component without a REUSE
tag, asserting a declaration nobody made and erasing the one thing the field
exists for -- showing where supplier and collector disagree. Where a tag did
exist it was promoted whatever file carried it, so OpenXR-SDK, a statically
linked Apache-2.0 SDK, was declared CC-BY-4.0 on the strength of the tag
licensing its COPYING.adoc prose. A tag speaks for the package only where the
file carrying it is a licence in its own right; otherwise the field is
unasserted.

`licenseInfoFromFiles` said NOASSERTION while 140 files in the same document
recorded Apache-2.0, which is the roll-up contradicting what it rolls up.

Every file said `copyrightText: NOASSERTION` although 140 state a notice on the
line above the identifier the collector already parses. It is read now rather
than discarded.

A vendored system library reported no version and a `pkg:generic` purl that
resolves to nothing, while dpkg -- already queried for the package name -- knows
`0.12.1-1build1.1`. Both now carry a `pkg:deb` purl a consumer can act on.

Signed-off-by: Andrei Aristarkhov <aaristarkhov@nvidia.com>
Four claims in the reference had drifted from the code, each one an audit
finding rather than a wording preference.

`.dist-info/sboms/` was described as holding the contents SBOM "and nothing
else". It is a shared directory and auditwheel already writes a CycloneDX
document of its own there, which this collector coexists with by looking for its
document by name.

"a dependency arriving under any of them lands with a real SPDX id" is not true
when the fetched licence list runs ahead of the grammar `license-expression`
ships: the id is identified but cannot be published as one, and travels as a
LicenseRef. The reference now separates identifying from naming, and says which
pin sets the floor.

The match thresholds were described as if both were strict. The coverage bound
is the looser one, and what it rules out is padding around a licence text rather
than a document that is mostly one.

Derived members were promised "the original digest". An archive member carries
one; a file matched only by its path was never hashed. The citation now carries
the digest where the input was hashed, and the reference says where that is.

Signed-off-by: Andrei Aristarkhov <aaristarkhov@nvidia.com>
A third audit round, run against the regenerated wheel.

The previous round's build-directory fix only half landed: the staged-component
map still built its keys with a literal `build/` while the index it is matched
against used the real directory name. The whole branch was dead in CI, and the
MJCF-stripped assets it exists for came out attributed to nothing.

Identical bytes still settle nothing, but the directory a member was staged into
often does: it is already explained by exactly one of the candidates. MuJoCo's
licence text, and the CloudXR libraries both SDKs ship byte-for-byte, take that
component instead of naming every candidate -- which overstated what the others
contribute. Saying the build recorded nothing was untrue where it plainly had.

Checked-in third-party code was scanned for its copyright only where a compiler
saw it, so a holder whose code reaches the wheel as Python was reported as
contributing no file to a wheel that ships it. Every tracked file that can carry
a REUSE header is scanned now, and a wheel member resolving to repository source
credits the holder the scan found rather than dropping it.

The notices file carries each component's copyright line. For one that ships no
licence file of its own, the packaged reference text names no holder, so that
line was the only notice the wheel had and it was in the SPDX document alone.

Signed-off-by: Andrei Aristarkhov <aaristarkhov@nvidia.com>
Making the generated project a git checkout left its commit timestamped by the
wall clock. A commit hashes that timestamp, the collector records the commit,
and the wheel carries the record -- so the reproducibility test comparing two
builds of the same project passed only when both landed in the same second.

It caught a real property; the fixture was the thing that was not reproducible.

Signed-off-by: Andrei Aristarkhov <aaristarkhov@nvidia.com>
A fourth audit round. It confirmed the previous rounds' fixes landed and found
two of them had landed only on one of the two paths that needed them.

A wheel member whose bytes match a component's source was labelled derived
whatever the bytes said, so a verbatim copy was published as modified -- with an
"origin" digest equal to its own, and a notices line reading "copied in and
modified by the build". The repository branch beside it already made the
comparison; now both do.

The LicenseRef comment in the document said every one of them matched no text in
the SPDX corpus. One matched under an identifier the shipped grammar cannot
validate, which the report had been taught to distinguish and the document --
the artifact consumers read -- had not.

The README fallback found the licence section, used it to decide there was a
grant, then hashed and packaged the whole file. marchingcubecpp's 81-byte grant
shipped as 1899 bytes of build instructions and a screenshot link, and a real
licence quoted in such a section would sink below the coverage threshold on the
surrounding prose alone.

The licence report shortened every version to twelve characters. That is a
commit abbreviated; for `1.1.0-2build1.1` it produced `1.1.0-2build`, which
reads as a complete Debian version and is not one.

The manifest took its timestamp from the first wheel's, dating the set before
the last wheel it binds existed, and skipped the duplicate-filename check the
release path runs. Both paths merge the same way now.

A dependency generating sources into its own build tree had them counted as this
project's, which marks the target first-party and would leave a wheel member
built from it with no component edge. And an archive was indexed from anywhere
under the repository root, so one staged into an install prefix could have
explained a wheel member -- the build accounting for itself.

Signed-off-by: Andrei Aristarkhov <aaristarkhov@nvidia.com>
Two ways a wheel could pass `check` while shipping a file the document does not
account for. Both reproduced against a real wheel before and after.

The verifier took its excluded-files list out of the document it was verifying
and used it to suppress the coverage check, asserting only that the embedded
SBOM appeared in it. Adding a module, listing it as excluded and regenerating
RECORD produced a wheel that `check` called OK -- the command published to
consumers and run by CI. The list is exactly RECORD and the document; anything
else fails, and the check proceeds against the permitted set.

Tracing an origin starts from the staged file where there is one, which is
right: that is where the member came from. The member's own digest was then
never looked at again, so a member that differed from its staged twin was still
described as byte-identical to whatever explained the staged bytes, and the
member set that the gate requires every file of to be explained was effectively
the staged tree's. Where they differ the member was rewritten after staging, and
is reported as derived rather than as a copy -- which is what the reference
already said should happen.

Whether a member is an exact copy is now carried as its own fact rather than
inferred from the origin label. Inferring it meant a byte-identical file could
be COPY_OF or GENERATED_FROM depending on which index happened to hold the
bytes.

Signed-off-by: Andrei Aristarkhov <aaristarkhov@nvidia.com>
The rest of the fifth audit round, all of it one emitter or index behaving
unlike the one beside it.

Every content index pairs a first-wins map with a check for whether more than
one component holds the bytes. The build-id index had none, so a patched library
took whichever archive was walked first -- and the two CloudXR SDKs share five
build-ids under two different sets of terms. That is the exact case the build-id
route exists to serve, masked today only because those five still match by
content and never reach it.

SOURCE_DATE_EPOCH was honoured by one of the four things that stamp a time, so a
rebuild could be compared to a published wheel but not to its published
evidence, which is the chain the manifest binds. One clock now, in one place.

Verification accepted build evidence for a different wheel, comparing two
unrelated member sets and reporting the mismatch as missing attributions. It
checks the name the evidence records, the way the manifest check beside it
already did.

The publication gate counts a REUSE pool as a licence text and verification did
not, so a component could publish and then fail the check CI runs with a message
saying no text was found. And the build warned about one of the two gap lists it
carries, hiding the stricter one.

Removes `_file_license_refs`, dead since it was written and not completable: a
file tag naming a LicenseRef has no text to define it with, and an undefined
reference is caught at check time.

Signed-off-by: Andrei Aristarkhov <aaristarkhov@nvidia.com>
A sixth round, run adversarially against the gates rather than over the
document. Four of the attacks it built got through; all four are reproduced
here before and after.

A zip may carry two members under one name. Reading by name returns the last,
so the first was hashed by nothing, compared to RECORD by nothing and reported
as uncovered by nothing -- while which one a consumer extracts is up to their
unzip. Inserting a second `isaaccapture/__init__.py` into the published wheel,
touching nothing else, produced an archive all three gates called OK. Members
are read by handle now, and an archive naming one twice is refused. The rewrite
step is guarded too, since adding a member that already exists would create the
same condition.

Anything under `.dist-info/` was asserted to be metadata the build wrote, with
no probe of any kind -- the one route around the gate every other member passes.
A library dropped in there was published with a fabricated origin and never
reached the licence pipeline at all. Only the names a packaging backend and this
collector actually write are metadata; the rest is explained like everything
else or it stops the build.

`check-set` -- the release gate, and the command the reference tells a consumer
to run over a download -- never read the build evidence whose digest it verifies,
making it strictly weaker than the per-wheel check beside it.

The document states a digest for every licence text it read, and the wheel
packages those bytes; nothing compared them. A proprietary EULA could be swapped
for Apache-2.0 and re-sealed, leaving one archive making four statements about a
component with three contradicting the fourth. The document now records where it
packaged each text, and verification reads that path -- by path, because two
components can ship identical texts and a set membership test survives a swap
inside one of them.

Signed-off-by: Andrei Aristarkhov <aaristarkhov@nvidia.com>
…ng them

The whitelist that stopped `.dist-info/` being a blanket exemption still
exempted everything under `licenses/`, so the project's own texts were published
as "distribution metadata written by the build" when they are copies of files
this repository tracks -- `LICENSES/Apache-2.0.txt` in the wheel is byte for byte
the one in the checkout. An origin was asserted for files that have a real one.

They trace now. The texts this collector packages are unaffected: it attributes
those as it adds them, which is why they already read as its own evidence rather
than as metadata.

Doing that exposed the path tiebreak preferring a dependency's source tree over
this repository, which credited our BSL-1.0 pool text to an SDK that ships one
under the same two trailing path segments. A member matching a file this project
tracks is this project's copy; checked-in third-party code still carries its
holder, because that branch looks the owner up. One attribution changed in the
whole wheel, and it is that one.

Signed-off-by: Andrei Aristarkhov <aaristarkhov@nvidia.com>
A LicenseRef identifies a text, so components carrying byte-identical terms --
two builds of one SDK, a vendor licence applied across products -- were minting
an id each and carrying the whole text twice. Nothing about that is particular
to the components that happen to collide here: entries are grouped by their
text, and where several share one they share an id named after the text's
digest, so it belongs to no single component and reads as shared. Every
reference is rewritten to it.

The document is 5.6% smaller for this wheel, which is where its weight actually
was -- the texts are a fifth of it and the digests under a tenth.

Signed-off-by: Andrei Aristarkhov <aaristarkhov@nvidia.com>
A seventh round, adversarial again. It got five artifacts past the gates; all
five are refused now, and the attacks it failed to land are still refused.

Verification checked the document's file list and digests and nothing else the
document asserts. Every package-level statement -- which components are here,
under what terms, which text belongs to which -- could be deleted or rewritten
with `check` still green: 35 packaged licence texts removed, or a whole
component dropped, and the wheel still claimed to redistribute them. The build
gate asks whether a text existed to read; verification now asks whether the
wheel in hand still carries it.

The licence-text check read the expected digest out of the string it was
checking, so a rewrite consistent with itself was invisible -- while the sidecar
holding the true digest was open in the same call. Where the evidence is
supplied it is what the document is compared against. Without it a self-
consistent forgery remains undetectable, which is the trust model: this
describes a wheel, a signature attests one.

LicenseRef ids were minted from an archive's basename in one place and a soname
in another, while the package ids beside them use the component key. Two
archives in different directories sharing a name minted one id for two different
texts, and the last writer won -- handing one vendor's SDK another's terms. Ids
come from the key everywhere, and minting one twice for different text is an
error rather than an overwrite.

Also: the id rewrite introduced with shared texts was a substring replace, so a
ref that is a prefix of another was mangled into one nothing defines; a member
named as a directory could carry bytes nothing accounted for; a manifest could
advertise one filename twice, which the producer refuses and the verifier
resolved to the first match; a named-but-absent sidecar tracebacked; and the
grant-or-pool rule had a fourth spelling.

Signed-off-by: Andrei Aristarkhov <aaristarkhov@nvidia.com>
The wheel package declared a supplier, a licence and no copyright, beside files
that each state one -- the package saying less about itself than anything in it.
It carries the notices its own files state now, which is the same derivation the
licence roll-up beside it already uses.

A file attributed to a component is excluded: its notice belongs to that
component's package. The companion wheel ships no file bearing one and so still
asserts nothing, rather than being given a notice no file states.

Also corrects the coverage-threshold comment, which put a genuine match at 30%
or above; a Debian copyright file naming several licences around the one matched
sits at a quarter, and is the reason the threshold is where it is.

Signed-off-by: Andrei Aristarkhov <aaristarkhov@nvidia.com>
Files written in different years state one holder over different year ranges, so
the distribution's own package listed that holder three times over, differing
only in the years.

Merging the ranges would be the obvious fix and the wrong one: a union of 2021
and 2023 claims 2022, which no file claims. A notice is dropped only when its
holder matches another's and that other's years already contain its own, so what
is published is always a line some file states. Disjoint ranges both survive,
and different holders never merge.

Applies wherever notices are gathered, so a component assembled from several
vendored files reads the same way.

Signed-off-by: Andrei Aristarkhov <aaristarkhov@nvidia.com>
The distribution's supplier was a constant in the collector naming an
organisation, so a fork, a rename or a transfer would have left it stating the
wrong party with nothing to notice. The project already declares who it is in
its own packaging metadata, which the vendored-component scan has been reading
all along to decide which copyright holders are not its own.

Those names are kept as written now and lowercased where the comparison needs
it, rather than being folded on the way in and unavailable for anything else.

Several authors name no single supplier and none names nobody, so both give
NOASSERTION: SPDX takes one, and choosing would be this collector speaking for a
project rather than reading it.

Signed-off-by: Andrei Aristarkhov <aaristarkhov@nvidia.com>
A nested licence text was kept only when a copied wheel member came
from its directory, so a component that reaches a wheel by being
compiled in lost every bundled library's terms: jsoncpp in the OpenXR
loader, hidapi and yuv2rgb in SDL3, spdlog's bundled fmt. Compiled
members now record what their build read, from the compiler's
dependency output (Makefile depfiles, the Ninja deps log), falling back
to listed sources and include directories where a target has none. A
nested text is kept when code under its directory reached the member.
Nested rows are labelled as terms for part of the component, and a text
several ids share is shown as a choice between them.

Signed-off-by: Andrei Aristarkhov <aaristarkhov@nvidia.com>
A component's conclusion was built from the ids its grant texts matched,
and a LicenseRef stood in only when nothing matched, so a recognised
licence hid any text beside it that matched nothing: MIT beside an EULA
concluded MIT. Every grant text is now accounted for. Ids a text names
are concluded; a text that matches nothing, matches only a tie whose ids
share one reference text, needs an id the grammar cannot express, or is
explained for less than four fifths of its terms (copyright notices
aside) is carried verbatim as the component's LicenseRef beside them.
A tie names its plain id where every other id only qualifies it (MPL,
OFL, CAL). The verifier now compares licence expressions exactly and
holds a shared LicenseRef to the text its id names.

Signed-off-by: Andrei Aristarkhov <aaristarkhov@nvidia.com>
…ntity

git was asked for the remote and describe of every _deps/<name>-src,
and a tree fetched as an archive inside this checkout answered with the
checkout's own: OAK's SDL2 would ship as NVIDIA/IsaacCapture. git is now
asked only of a directory that is its own repository, with git's
repository-selecting environment stripped. Any other tree is identified
by what the build declared, read from FetchContent's stamps in both
population modes (the tag from the clone script they name): a GitHub
archive by its repository and ref, any other URL as a generic package
with no supplier. Published locations drop credentials, query and
fragment.

Signed-off-by: Andrei Aristarkhov <aaristarkhov@nvidia.com>
…nifest lists

check-set iterated `manifest.get("wheels") or []`, so a manifest with
no wheels checked nothing and printed OK; merge-manifests merged one.
Every command reading a manifest now refuses one that is not an object,
lists no wheel, or holds a record without a bare file name or with
sidecars that name no file and digest, naming the file it read.
check-set also fails a wheel in --wheel-dir the manifest does not list,
and publish-wheel applies the same rule before it uploads, since every
wheel there is published.

Signed-off-by: Andrei Aristarkhov <aaristarkhov@nvidia.com>
A component whose texts were only a REUSE pool shipped those texts, so
it escaped "no evidence", and concluded NOASSERTION, so it escaped the
carried list; the report then said every component ships an identified
license. A component concluding NOASSERTION is now listed under its own
gap, with a reason naming its texts, and the summary line appears only
when every gap list is empty. GAP_LISTS is the one source for the
report's lists, the Markdown headings, the manifest and the CI
warnings. The wheel's aggregate licence is NOASSERTION when any
component it ships is, rather than leaving that component out.

Signed-off-by: Andrei Aristarkhov <aaristarkhov@nvidia.com>
… is no grant

sbom.rst said terms stated in source headers stand in for a licence
file for every component. Only checked-in third-party files are read
that way; a fetched tree's headers, sidecars and REUSE.toml decide
nothing, so one with no licence file at its root, no LICENSES/ pool and
no README licence section blocks publication. That was not true either:
a REUSE `.license` sidecar at a fetched tree's root was taken as its
grant, so a two-line SPDX tag shipped as its terms. A file is now a
sidecar only when the file it annotates sits beside it, and a sidecar is
never a grant: at a tree's root, an archive's root, beside a toolkit
library, or as a README.

Signed-off-by: Andrei Aristarkhov <aaristarkhov@nvidia.com>
No document carries externalDocumentRefs: a sibling wheel is named as a
package, not bound to its document's digest. Yet the CLI still built
wheels in dependency order, recorded each document's `describes` digest
in the manifest and overwrote sibling facts from it, all to support a
reference nothing emits and nothing reads. Remove in_dependency_order,
_describes, the manifest's `describes` key and the never-filled
external-document list. siblings_of already supplies every sibling
field the document reads, and the validator still checks any
externalDocumentRefs a consumer meets.

Signed-off-by: Andrei Aristarkhov <aaristarkhov@nvidia.com>
build --wheel is rewritten in place and --out-dir receives the
sidecars and manifest.json, but neither said so in --help; nor did
check --wheel, check-set --manifest/--evidence-dir or merge-manifests'
arguments. Every option now has a one-line description.

Signed-off-by: Andrei Aristarkhov <aaristarkhov@nvidia.com>
sbom.rst called PEP 740 attestations "the provenance mechanism", which
read as if these releases supplied them. publish-wheel uploads with a
plain twine upload and no id-token, so they do not. Say it is the
ecosystem's mechanism and that these releases are not published with
attestations.

Signed-off-by: Andrei Aristarkhov <aaristarkhov@nvidia.com>
_INCLUDE_FLAG and FIRST_PARTY had no users, read_notice was called only
by tests that read_notices covers, and siblings_of filled name and
wheel keys that no document reads.

Signed-off-by: Andrei Aristarkhov <aaristarkhov@nvidia.com>
…t them

Comments in the collector recounted earlier failures (a set dated
before its last wheel, a pinned sibling, an entry naming only the last
components, a pool text credited to an SDK). Restate each as the
invariant it protects, per the repo's comment rules.

Signed-off-by: Andrei Aristarkhov <aaristarkhov@nvidia.com>
A system package's purl took its namespace from the build machine's
os-release, so CUDA installed from NVIDIA's apt repository was published
as pkg:deb/ubuntu/cuda-cudart-dev-*: a vendor claim that vulnerability
matching acts on.

The namespace is now the apt Origin of the sources listing the installed
version, or, where no archive lists that version, of those listing its
other versions; it must be a single Origin, compared ignoring case. A
package whose vendor cannot be named that way (a local .deb, archives
that disagree or state no Origin, an Origin that is a URL or host name)
is pkg:generic/<package>@<version>, since pkg:deb requires its vendor.
Build evidence records apt_origin and apt_origin_from.

The owner is read only from dpkg-query -S's answer line, so translated
diversion lines are never taken for a package and a diverted path names
none; apt and dpkg-query -W are asked with the architecture-qualified
name dpkg reports. apt output is parsed by layout, not by its translated
headings, and tolerates phased-update notes.

Signed-off-by: Andrei Aristarkhov <aaristarkhov@nvidia.com>
Signed-off-by: Andrei Aristarkhov <aaristarkhov@nvidia.com>
A file's SPDXID was minted from its position in the sorted wheel listing,
so one added file renumbered every later file and every relationship that
named one, and two releases' documents could not be diffed.

The ID is now the member's path, reduced to the characters an SPDX
identifier allows, plus the first 8 hex digits of a sha256 of that path.
The versioned <name>-<version>.dist-info/ and .data/ directories read as
dist-info/ and data/, so third-party licence texts and metadata keep their
IDs across a version bump; the digest separates paths that sanitise alike
and, being taken over a form no member name can spell for those two
directories, a real top-level data/ tree. Two members that would still
share an ID stop the build, naming both.

Signed-off-by: Andrei Aristarkhov <aaristarkhov@nvidia.com>
…s tests with it

The collector was importable as plain `sbom`, the only generic top-level
name in the repo; it is now `isaaccapture_sbom`, matching the
`isaaccapture_<name>` convention. The distribution (isaaccapture-sbom),
the CLI and `uv run --project scripts/sbom isaaccapture-sbom` are
unchanged.

Its tests move from tests/python/sbom to scripts/sbom/tests and run from
the collector's own pyproject, so the project reaches nothing through
`../`: no separate tests project, no path dependency back to the
collector, no repo_paths import. The repo root is found by the same
VERSION + CMakeLists.txt walk, and CTest still registers them as
sbom_<test> with the same environment, from scripts/sbom/CMakeLists.txt.

    uv run --project scripts/sbom --extra dev pytest scripts/sbom/tests

Signed-off-by: Andrei Aristarkhov <aaristarkhov@nvidia.com>
docs/source/references/sbom.rst had grown into a maintainer reference
few people would read through. It is now the page a wheel consumer
needs: what a wheel carries, what a release publishes beside it, and how
to verify either.

How the collector works moves to scripts/sbom/README.md, next to the
code: running and testing it, how the inventory and licenses are worked
out, and what stops the Release build, each condition with its remedy.
The two link to each other and state each fact once. Root AGENTS.md
points agents at the README with one rule (fix the cause; never exclude
a file or record an assumed license), and every build error that stops
a Release build names that README section.

Also corrected where the split exposed them: license.rst no longer says
every SPDX header must be Apache-2.0 (checked-in third-party files carry
upstream's terms, EgoPost its own), and the build prerequisites say a
Release wheel with its SBOM needs CMake 3.27.

Signed-off-by: Andrei Aristarkhov <aaristarkhov@nvidia.com>
The collector's tests ran inside the general ctest step, ahead of SBOM
generation only by accident of step order. They now carry the ctest
label `sbom` and run as their own Release-only step, "Test the SBOM
collector", straight after the general tests (which exclude the label),
so a Release leg packages SBOMs only once they have passed.

The gate cannot pass on nothing: `--no-tests=error` fails it when no
test carries the label, and a path CTest names for the SPDX license data
or the build tree that holds nothing now fails the tests instead of
skipping them. Run outside CTest with nothing configured, they still
skip.

Signed-off-by: Andrei Aristarkhov <aaristarkhov@nvidia.com>
… CMake

The collector is a uv project of its own, and so are its tests: the
product's CMake no longer registers them and CTest no longer runs them.
Ubuntu's Release legs run them with uv directly, before any SBOM is
generated, so a failure still stops generation.

The tests take one input, the build tree ISAACCAPTURE_SBOM_BUILD_DIR
names, and find the SPDX list in it the way the collector does;
ISAACCAPTURE_SBOM_LICENSE_DATA is gone. A named tree that lacks either
input fails the tests rather than skipping them.

Signed-off-by: Andrei Aristarkhov <aaristarkhov@nvidia.com>
The collector's tests need a configured tree, not a built one, so they
no longer wait on the product build. A job per arch configures the
product as Release, without plugins, examples, viz or bindings, and runs
the tests under every Python a Release leg runs the collector with,
reporting each one that fails.

wheel-gate needs the job, so nothing publishes unless it passes. SBOM
generation in the Release legs no longer waits for it.

Signed-off-by: Andrei Aristarkhov <aaristarkhov@nvidia.com>
@github-actions

github-actions Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

✅ Preview deployed: https://NVIDIA.github.io/IsaacCapture/preview/pr-1174/

Built from commit d5189f4. It may take a minute for the updated preview to appear.

…he build

The licence report listed every component the build resolved that reached no
file in the wheel. That list describes the build, so the metadata-only
isaacteleop wheel -- byte-identical in every CI leg -- got one report on x64
and another on arm64, each naming that arch's CloudXR SDK archives, and a
release published both.

Drop it from licenses.json and licenses.md, and drop the copy build evidence
kept as excluded_components: each entry of its components map already says
in_this_wheel and kind, which is the same record. The report also stops
copying license_data.source, the build machine's path to the SPDX list; the
build evidence keeps it.

Signed-off-by: Andrei Aristarkhov <aaristarkhov@nvidia.com>
Every Release matrix entry builds the py3-none-any isaacteleop wheel, so its
sidecars reach the release job once per entry. The job's shell loop kept the
first copy under its own name and any differing one as <entry>.<name>: files
no manifest bound and check-set never read, with the plain name chosen by
glob order and the bound one by merge_records' own.

merge-manifests now refuses one wheel name whose SBOM or license report
differs between entries, as it already refused differing wheel bytes; build
evidence may differ, and the first entry's is bound. --evidence-out copies
exactly the bound sidecars, from beside the record bound, after checking each
against its digest, and the workflow's loop goes. check-set fails a file in
--evidence-dir the manifest does not name, as it already did a wheel, so what
the release uploads and what it verified are one set.

Signed-off-by: Andrei Aristarkhov <aaristarkhov@nvidia.com>
Signed-off-by: Andrei Aristarkhov <aaristarkhov@nvidia.com>

# Conflicts:
#	docs/source/development/license.rst
A tag rehearsal showed GitHub renames release assets: the epoch wheel
isaacteleop-1!X.Y.Z's sidecars were published as isaacteleop-1.X.Y.Z-*, so
check-set on the downloaded release found four files missing and four it did
not bind. Names inside a zip are kept as written.

The release job pins the clock to the commit, merges straight into an
isaaccapture-sbom-<tag>/ directory, and zips it with fixed times, modes and
order and no extra fields, so a rerun writes the same bytes. It runs check-set
on what unpacking the zip gives, as the docs tell consumers to, and attaches
only the zip.

The manifest's bound file names get one definition, shared by merge-manifests
and check-set.

Signed-off-by: Andrei Aristarkhov <aaristarkhov@nvidia.com>
Signed-off-by: Andrei Aristarkhov <aaristarkhov@nvidia.com>
@aristarkhovNV
aristarkhovNV enabled auto-merge (squash) October 22, 2026 19:08
@aristarkhovNV
aristarkhovNV merged commit 8b031d1 into NVIDIA:main Oct 22, 2026
36 checks passed
github-actions Bot added a commit that referenced this pull request Oct 22, 2026
@aristarkhovNV
aristarkhovNV deleted the sbom-upstream branch October 22, 2026 20:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants