Skip to content

Add THIRD_PARTY_NOTICES.md and tooling to generate it - #2723

Open
abrarshivani wants to merge 11 commits into
NVIDIA:mainfrom
abrarshivani:third-party-notices
Open

Add THIRD_PARTY_NOTICES.md and tooling to generate it#2723
abrarshivani wants to merge 11 commits into
NVIDIA:mainfrom
abrarshivani:third-party-notices

Conversation

@abrarshivani

@abrarshivani abrarshivani commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds THIRD_PARTY_NOTICES.md, a single file carrying the license of every Go dependency behind the released artifacts, and tools/generate-notices which produces it. make notices regenerates it, make notices-check verifies it is current, and a CI job runs that check on every build.

Note for reviewers

~98% of this diff is generated output and does not need reading. Of 28,570 added lines, 27,875 are machine-produced: THIRD_PARTY_NOTICES.md is the tool's output, and tools/go.sum comes from go mod tidy.

The hand-written change is 695 lines across 8 files:

File Lines What it is
tools/generate-notices 503 The main thing to review. The generator: named functions behind a main(), ~75 lines of it comments.
.github/workflows/release-third-party-notices.yaml 94 Attaches the notices file to the GitHub Release
.github/workflows/notices-check.yaml 62 CI job: regenerate and diff
Makefile 19 notices and notices-check targets
tools/go.mod 7 Pins go-licenses v2.0.1
.github/workflows/ci.yaml 6 Calls the new job
.gitignore 3 Ignores /.licenses-cache, the generator's scratch tree
tools/tools.go 1 go-licenses import so install-tools builds it
Generated — skim or skip Lines
THIRD_PARTY_NOTICES.md 27,778
tools/go.sum 97

If you only read one thing, read tools/generate-notices. To spot-check the output, the two index tables at the top of THIRD_PARTY_NOTICES.md are the summary; everything below them is verbatim license text.

How THIRD_PARTY_NOTICES.md is generated

Prerequisites: Go (version from versions.mk) and go-licenses, which make install-tools puts in ./bin from the pin in tools/go.mod. The main module is vendored, so the runtime pass needs no network; the toolchain pass resolves from the module cache and will download on a cold one.

make notices          # regenerate
make notices-check    # regenerate and fail if the committed file differs

What tools/generate-notices does, in order:

  1. Checks prerequisites — finds go and go-licenses, preferring ./bin/go-licenses over $PATH so the pinned version is the one that runs.
  2. Verifies the platform matrix — reads DOCKER_BUILD_PLATFORM_OPTIONS from multi-arch.mk and fails if the script's PLATFORMS array has drifted from the platforms the image is actually released for.
  3. Collects runtime licenses, once per platform. For each of linux/amd64 and linux/arm64, with GOFLAGS=-mod=vendor and CGO_ENABLED=0: runs go-licenses save to copy every license file into .licenses-cache, and go-licenses csv to record the classifications. Results are unioned across platforms. Only the local module is passed to --ignore — go-licenses excludes the standard library itself, and --ignore matches raw string prefixes, so anything shorter and more generic silently drops real dependencies.
  4. Collects build toolchain licenses the same way, from tools/go.mod, over four platforms including darwin. The tool list is read out of tools/tools.go rather than duplicated, so it cannot drift from what make install-tools builds.
  5. Collapses the rows. Sorts whole lines to get a total order, then joins every distinct license for a package rather than keeping one, so dual- and multi-licensed dependencies report all of them.
  6. Attaches module versions — maps each package to its providing module by longest-prefix match against vendor/modules.txt, giving the exact module@version.
  7. Composes the document — header, an index table per surface, then each dependency's license text verbatim, fenced with a backtick run long enough to survive licenses that are themselves Markdown.

7 writes to a temp file and moves it into place, so an interrupted run cannot leave a truncated committed file.

Output is deterministic: LC_ALL=C sorting and the platform union make it byte-identical regardless of which machine runs it — verified by generating on macOS and in a Linux container and comparing sha256.

To add a dependency, nothing special is needed — change go.mod, run go mod vendor, then make notices and commit the result. CI fails the PR if you forget.

Scope

This document covers Go dependencies. It does not inventory the non-Go contents of the released image — the packages inherited from the base image, the BusyBox tree, the CUDA sample, or the CUDA compatibility libraries. Base image dependencies are covered by that image's own compliance process, and the remaining components are handled through separate flows, including any source-distribution obligations they carry. The generated file states this in its header rather than claiming to cover everything shipped.

How the file is shipped

The notices file is not copied into the runtime image, so image size is unchanged. It is published as a GitHub Release asset instead: release-third-party-notices.yaml triggers on release: published, checks out the tagged commit and uploads the committed file, failing if it is missing or empty. Nothing is regenerated at release time — notices-check already proves the committed file matches the tree. The workflow follows release-image-list.yaml, which attaches a generated artifact the same way. It also accepts workflow_dispatch with a tag, for backfilling an existing release.

Attaching to the image in the registry was considered and deferred, and the workflow records why. nvcr.io does not implement the OCI 1.1 referrers API — GET /v2/nvidia/gpu-operator/referrers/<digest> returns {"code":"UNSUPPORTED"}. The repository does carry per-digest artifacts through the cosign tag scheme (240 sha256-<digest>.{sig,sbom,vex} tags, including SPDX SBOMs), but those are attached by the downstream publishing pipeline, not by anything in this repository. Binding notices to an image digest is therefore a request to that pipeline rather than a change here, and worth revisiting once we know whether OSRB wants digest-bound license texts alongside the existing SBOM.

Why a wrapper instead of go-licenses directly

go-licenses resolves the dependency graph for a single platform, the host's. Build-tagged sources pull in different transitive dependencies per platform, so running it once gives a file that is both incomplete — missing everything the non-host platforms bring in — and dependent on who ran it, which would make any freshness check flap between a macOS laptop and a Linux runner.

tools/generate-notices runs it once per target, unions the results, and sorts deterministically so the output is byte-identical on any host. The runtime matrix is the image platforms from multi-arch.mk, and a guard fails the run if the array and the makefile drift apart. The toolchain matrix additionally covers darwin, since those binaries get built on developer machines as well as in CI.

Correctness details worth review

Multi-licensed dependencies report every license. go-licenses emits one row per recognized license. Collapsing on the package field kept exactly one: filepath-securejoin showed as BSD-3-Clause with its MPL-2.0 disclosure dropped, and klauspost/compress, sigs.k8s.io/yaml and sigs.k8s.io/json each lost two. Which row survived was also unstable — sort -u with a key compares only that key, so BSD sort keeps whichever came first in input order while GNU sort applies a whole-line tiebreak, so macOS and Linux could disagree. Sorting whole lines first gives a total order, and licenses are now joined rather than discarded.

Vendored dependencies are identified by module@version. In vendor mode go-licenses reports a URL into this repository at HEAD, which stops describing released content once main advances and names our vendor copy rather than upstream. vendor/modules.txt has the exact version behind every vendored package, so entries carry that instead. All 124 resolve.

Scope of the runtime scan

Scoped to ./cmd/... rather than ./.... That is exactly the set make cmds builds — CMDS is a wildcard over ./cmd/*, and the Dockerfile builds the image by running make cmds — so it tracks the build definition rather than a hand-maintained list. Measured against ./..., the only difference is github.com/onsi/ginkgo/v2 and github.com/onsi/gomega/format from the e2e suite, which is never shipped.

It errs the other way on gpuop-cfg: that command is scanned even though it is a build/CI helper not copied into the image. Attributing slightly more than is redistributed is the safe direction.

CI

The notices-check job is wired into ci.yaml and runs on every build, with no changed-paths filter.

A filter was tried and removed. The runtime inventory is the import closure of ./cmd/..., so it changes when ordinary .go files change their imports, not only when go.mod or vendor/ move — a filter keyed on dependency manifests passes green while the committed file goes stale, and the failure then surfaces on main instead of on the PR that caused it. Its base computation was also broken: git fetch --depth=1 on an already-full clone writes .git/shallow, after which git merge-base finds no common ancestor and the step dies with exit 128. A gate that can silently pass is worth less than the runner minutes it saves.

This job has now run on real CI and passed (1m18s), which also confirms the generated file reproduces byte-identically on the Linux runner from a file generated on macOS.

make notices-check also fails if the file is untracked, since git diff reports nothing for an untracked path and the gate would otherwise pass silently.

Testing

Current output: 124 runtime packages, 74 build toolchain packages, 1.44 MB. (Index rows are per package; one module can own several.)

  • Cross-host reproducibility, proven not assumed. The generator was run under golang:1.26.5 (GNU coreutils, gawk) and on macOS (BSD userland), and the two outputs compared: identical sha256. This is the property the whole multi-platform design rests on, and it is now verified rather than caveated. Two consecutive runs on each host are also byte-identical.
  • Completeness against independent ground truth. The runtime index was compared against the dependency set derived separately via go list -deps ./cmd/... for both released platforms, mapped to license-owning directories: 124 expected, 124 present, 0 missing, 0 extra.
  • Every index row has a matching license-text section; zero Unknown licenses, zero "License text unavailable", zero unresolved module versions.
  • Multi-licensed dependencies verified against the actual license files, including filepath-securejoin (BSD-3-Clause / MPL-2.0), klauspost/compress, sigs.k8s.io/yaml, sigs.k8s.io/json.
  • make notices-check passes against the committed file, and was observed failing correctly when the file was stale and when it is untracked.
  • Error paths exercised: missing input file, and a deliberately drifted multi-arch.mk (platform matrix guard) — both exit 1 with actionable messages.
  • shellcheck clean on the generator; actionlint and yamllint clean on both workflows.
  • New guards exercised directly: a replace directive in vendor/modules.txt (uses the replacement), a filesystem replace (fails loudly), and an unreadable module list (fails loudly rather than labelling every row unknown).

Bugs found and fixed during review

Three defects were found by review and testing after the initial version, all now fixed and verified:

  1. 18 runtime modules and 14 toolchain modules had no attribution at all — including google.golang.org/protobuf, all nine golang.org/x/*, go.uber.org/zap and gopkg.in/yaml.v3. The stdlib ignore list was built with go list std | cut -d/ -f1, which yields the bare token go (from go/ast, go/build). go-licenses matches --ignore with strings.HasPrefix on the import path rather than by path segment, so go silently swallowed every module whose path starts with those two letters. The list was never needed — go-licenses already excludes the standard library itself — so it is gone. Runtime went 106 → 124.
  2. The output differed between Linux and macOS on 664 lines. In lic[pkg] = (pkg in lic) ? ... : ..., mawk instantiates the assignment target before evaluating the right-hand side, so the separator was prepended to a value that did not exist yet and every license read " / BSD-3-Clause". mawk is /usr/bin/awk on stock Debian and Ubuntu; BSD awk and gawk both behave the other way, which is why it never showed up locally. This would have failed notices-check on the first CI run.
  3. Two locale- and awk-dependent failures that produce a wrong file while exiting 0. The license-filename match lacked LC_ALL=C, so under a Turkish locale LICENSE stops matching and the document silently collapses from 1.4 MB to 57 KB of "License text unavailable". The fence-width grep lacked -a, so a license containing a NUL byte would produce a fence whose width depends on the grep version and the path length. Neither triggers on today's dependency set; both are one token to fix.
  4. replace directives in vendor/modules.txt were misattributed to the original module path and version rather than the code actually vendored, and an unreadable module list labelled every entry unknown without failing.
  5. The CI path filter could pass green while the file went stale. The runtime inventory is the import closure of ./cmd/..., so it changes when ordinary .go files change imports — which the filter did not watch. Its base computation was also broken: git fetch --depth=1 on an already-full clone writes .git/shallow, after which git merge-base finds no common ancestor and the step dies with exit 128; and it diffed against main even for cherry-pick PRs targeting release-*. The filter is removed and the check now runs on every build.

Add tools/generate-notices, which aggregates the license of every
third-party dependency behind the released artifacts into a single
THIRD_PARTY_NOTICES.md, along with make targets to generate and verify
it.

Two surfaces are covered. The runtime section collects the Go modules
linked into the commands under ./cmd, four of which docker/Dockerfile
copies into the image, read offline from the committed vendor tree. The
build toolchain section covers the module graph behind tools/go.mod;
that module is not vendored, so it resolves from the module cache.

go-licenses only ever resolves one platform, the host's, and
build-tagged sources pull in different transitive dependencies per
platform. Running it once would produce a file that is both incomplete,
missing every non-host platform, and dependent on who generated it, so a
freshness check would flap between a macOS laptop and a Linux runner.
The script therefore runs it once per target, unions the results, and
sorts with LC_ALL=C so the output is the same bytes on any host. The
runtime matrix is the image platforms from multi-arch.mk, and a guard
fails the run if the two drift apart. The toolchain matrix additionally
covers darwin, since those binaries get built on developer machines too.

The runtime scan is scoped to ./cmd/... rather than ./..., which is
exactly the set 'make cmds' builds and therefore what the image build
produces. It excludes the e2e suite, which belongs to this module but is
never shipped and whose ginkgo and gomega dependencies do not belong in
a notices file. It errs the other way on gpuop-cfg, a build helper that
is scanned despite not being shipped; attributing slightly more than is
redistributed is the safe direction here.

make notices regenerates the file and make notices-check regenerates and
diffs it, for use on dependency changes.

Signed-off-by: Abrar Shivani <ashivani@nvidia.com>
Output of make notices: 106 runtime modules and 60 build toolchain
modules. Regenerate with make notices whenever dependencies change.

Signed-off-by: Abrar Shivani <ashivani@nvidia.com>
@abrarshivani abrarshivani self-assigned this Aug 7, 2026
Addresses review feedback on the notices generator.

Report every license of a multi-licensed dependency. go-licenses emits
one row per recognized license, and collapsing on the package field kept
exactly one of them: filepath-securejoin appeared as BSD-3-Clause with
its MPL-2.0 disclosure dropped, and klauspost/compress, sigs.k8s.io/yaml
and sigs.k8s.io/json each lost two. Which one survived was also not
stable. 'sort -u' with a key compares only that key, so BSD sort keeps
whichever row came first in input order while GNU sort applies a
whole-line tiebreak, meaning a macOS laptop and a Linux runner could
disagree on the output. Sorting whole lines first gives a total order,
and the licenses are now joined rather than discarded.

Identify vendored dependencies by module and version. In vendor mode
go-licenses reports a URL into this repository at HEAD, which stops
describing the released content once main advances and names our vendor
copy rather than upstream. vendor/modules.txt has the exact version
behind every vendored package, so entries now read module@version.

State the scope. The document claimed to list every third-party
dependency the operator redistributes, but it covers Go dependencies
only; the image also carries its base image's packages, a BusyBox tree,
a CUDA sample and the CUDA compatibility libraries. Those are handled
through the base image's compliance process and separate flows rather
than here, so the header now says what is and is not covered instead of
overclaiming.

Enforce freshness. notices-check existed but nothing ran it, leaving a
generated compliance artifact to drift until someone noticed. A CI job
now regenerates and diffs whenever anything that can change the
inventory moves, including the generator itself.

Signed-off-by: Abrar Shivani <ashivani@nvidia.com>
Same rationale, fewer words. No code changes - the generated file is
byte-identical and every non-comment line is unchanged.

Signed-off-by: Abrar Shivani <ashivani@nvidia.com>
Group the script into named functions behind a main(), add die() and log()
helpers in place of the repeated echo-and-exit blocks, and pull the
duplicated stdlib-ignore and tree-merge logic into stdlib_prefixes() and
merge_licenses(). Comments drop from 103 lines to 75, keeping the
rationale that is not recoverable from the code.

The generated file is byte-identical before and after.

Signed-off-by: Abrar Shivani <ashivani@nvidia.com>
Attribution for a release should travel with it rather than only living
in the repository, so attach the notices file to the GitHub Release on
publish. This follows release-image-list.yaml, which already attaches a
generated artifact the same way.

Nothing is regenerated here. The file is committed and notices-check
proves it matches the tree, so the job checks out the tagged commit and
uploads what is there, failing if the file is missing or empty. It is
deliberately not copied into the runtime image, which leaves the image
size unchanged.

Attaching to the image in the registry was considered and deferred.
nvcr.io does not implement the OCI 1.1 referrers API - the endpoint
returns UNSUPPORTED - though the repository does carry per-digest
artifacts through the cosign tag scheme, including SPDX SBOMs. Those are
attached by the downstream publishing pipeline rather than by anything
in this repository, so binding notices to an image digest is a request
to that pipeline rather than a change here. The workflow records this.

Also quote GITHUB_ENV in the notices-check job, which actionlint flagged.

Signed-off-by: Abrar Shivani <ashivani@nvidia.com>
Running the generator under GNU coreutils produced a THIRD_PARTY_NOTICES.md
that differed from the macOS one on 664 lines: every license field gained a
leading " / ", so the Linux output read " / BSD-3-Clause" where macOS read
"BSD-3-Clause".

The cause is an awk portability trap in collapse_index. Given

    lic[pkg] = (pkg in lic) ? lic[pkg] " / " $3 : $3

gawk instantiates the assignment target before evaluating the right-hand
side, so "pkg in lic" is already true on the first row for a package and the
separator is prepended to a value that is not there yet. BSD awk does not
instantiate it, which is why this never showed up locally. Counting
occurrences instead is well defined on both.

This would have failed notices-check on the first CI run, since the
committed file is generated on a developer machine and regenerated on a
Linux runner.

Verified by generating the file in a golang:1.26.5 container and comparing
with the macOS output: identical sha256 after the fix, where before they
differed. The committed file is unchanged - macOS was producing the correct
bytes all along.

Signed-off-by: Abrar Shivani <ashivani@nvidia.com>
THIRD_PARTY_NOTICES.md was missing 18 runtime modules covering 74 linked
packages, and 14 build toolchain modules. Among them:
google.golang.org/protobuf, all nine golang.org/x/* modules,
go.uber.org/zap, go.yaml.in/yaml/v2 and v3, gomodules.xyz/jsonpatch/v2,
gopkg.in/inf.v0, gopkg.in/yaml.v3 and gopkg.in/evanphx/json-patch.v4.
All are statically linked into the released binaries and had no
attribution at all.

The cause was the stdlib ignore list. It was built with

    go list std | cut -d/ -f1

which yields bare first path elements, including "go" from go/ast and
go/build. go-licenses matches --ignore entries with strings.HasPrefix on
the import path, not by path segment, so "go" matched golang.org/...,
google.golang.org/..., gopkg.in/..., go.uber.org/... and go.yaml.in/...
and dropped them silently.

The list was never needed: go-licenses already excludes the standard
library through its own isStdLib check, which works under cross-GOOS
listing. Passing only the local module fixes the omission and removes the
machinery. Verified by running go-licenses with and without the list:
106 packages against 124, the difference being exactly the missing set.

Runtime entries go from 106 to 124 and toolchain from 60 to 74. The
regenerated file is included.

Also compose the document into a temp file and move it into place, so a
failure part way through no longer leaves a truncated committed file in a
developer's worktree.

Signed-off-by: Abrar Shivani <ashivani@nvidia.com>
The changed-paths filter was wrong in three independent ways, so remove
it rather than patch it.

It could not see the change it most needed to catch. The runtime
inventory is the import closure of ./cmd/..., so it moves when ordinary
.go files change their imports, not only when go.mod or vendor/ move. A
PR adding an import of an already-vendored module would pass green and
the failure would surface later on main.

Its base computation was also broken. 'git fetch --depth=1' on the full
clone that fetch-depth: 0 had just made writes .git/shallow and grafts
main's tip as a parentless commit, after which git merge-base finds no
common ancestor and exits 1. The failure sat inside a command
substitution, so the empty result reached git diff as a revision and the
step died with exit 128 rather than reporting anything useful. It also
diffed against main unconditionally, which is the wrong base for the
cherry-pick PRs this repo opens against release-* branches.

A gate that can silently pass is worth less than the runner minutes it
saves, so the job now checks out, installs Go and runs make
notices-check. This also drops the fetch-depth: 0 checkout it needed.

In notices-check, do the cheap tracked-file test before spending minutes
regenerating. In the release workflow, pass the tag through the
environment rather than interpolating it into the script, matching
release.yaml and release-image-list.yaml; git allows shell
metacharacters in tag names.

Signed-off-by: Abrar Shivani <ashivani@nvidia.com>
Follow-up from review. The output is byte-identical before and after;
these close latent failures rather than change today's result.

Correct the attribution in the collapse_index comment. The array
instantiation quirk is mawk and busybox awk, not gawk - gawk and BSD awk
both behave the same way. Verified on mawk 1.3.4 (Debian's /usr/bin/awk,
and what the golang container uses) against gawk 5.2.1. The earlier
commit message on this fix named the wrong implementation. The split is
by awk, not by OS, and saying so matters: a reviewer who checks the claim
on gawk would find it false and might revert the guard.

Add LC_ALL=C to the two remaining locale-sensitive greps. Every sort here
already had it. The license-filename match did not, and under a Turkish
locale glibc does not fold I to i, so LICENSE stops matching and every
section degrades to "License text unavailable" - a 1.4 MB document
becoming 57 KB while still exiting 0.

Pass -a to the fence-width grep. A license containing a NUL byte is
otherwise treated as binary, and grep prints a message instead of the
matches; older greps put it on stdout and newer ones on stderr, so the
fence width would differ by host and even vary with the path length.

Handle replace directives in vendor/modules.txt. Only the left side was
parsed, so a replaced module would be attributed to the original path and
version rather than the code actually vendored. Now the replacement is
reported, and a filesystem replace stops the run rather than misstating
it. Also fail when no module lines are read at all, which previously
labelled every entry "unknown" without failing.

Fail when go-licenses cannot resolve a toolchain source URL. Those are
looked up over the network for non-github hosts and fall back to
"Unknown" with a zero exit, so a blocked proxy would quietly drop ~17
URLs and look like a dependency change.

Keep the compose temp file under the EXIT trap, and call index rows
packages rather than modules - one module can own several rows.

Signed-off-by: Abrar Shivani <ashivani@nvidia.com>
annotate_modules emits "unknown" when longest-prefix matching against
vendor/modules.txt finds nothing, but nothing rejected it, so a degraded
attribution could be rendered and committed. That silently understates
what is shipped.

The sibling generators in mig-parted, k8s-device-plugin and
k8s-driver-manager already guard this; this brings gpu-operator in line.
Output is unchanged - all 124 runtime rows resolve today.

Signed-off-by: Abrar Shivani <ashivani@nvidia.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant