Add THIRD_PARTY_NOTICES.md and tooling to generate it - #2723
Open
abrarshivani wants to merge 11 commits into
Open
Add THIRD_PARTY_NOTICES.md and tooling to generate it#2723abrarshivani wants to merge 11 commits into
abrarshivani wants to merge 11 commits into
Conversation
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
force-pushed
the
third-party-notices
branch
from
August 7, 2026 19:07
f102f7c to
7d621df
Compare
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>
abrarshivani
marked this pull request as ready for review
August 7, 2026 22:01
abrarshivani
requested review from
cdesiniotis,
karthikvetrivel,
rahulait,
rajathagasthya,
shivamerla and
tariq1890
as code owners
August 7, 2026 22:01
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds
THIRD_PARTY_NOTICES.md, a single file carrying the license of every Go dependency behind the released artifacts, andtools/generate-noticeswhich produces it.make noticesregenerates it,make notices-checkverifies 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.mdis the tool's output, andtools/go.sumcomes fromgo mod tidy.The hand-written change is 695 lines across 8 files:
tools/generate-noticesmain(), ~75 lines of it comments..github/workflows/release-third-party-notices.yaml.github/workflows/notices-check.yamlMakefilenoticesandnotices-checktargetstools/go.modgo-licenses v2.0.1.github/workflows/ci.yaml.gitignore/.licenses-cache, the generator's scratch treetools/tools.gogo-licensesimport soinstall-toolsbuilds itTHIRD_PARTY_NOTICES.mdtools/go.sumIf you only read one thing, read
tools/generate-notices. To spot-check the output, the two index tables at the top ofTHIRD_PARTY_NOTICES.mdare the summary; everything below them is verbatim license text.How THIRD_PARTY_NOTICES.md is generated
Prerequisites: Go (version from
versions.mk) andgo-licenses, whichmake install-toolsputs in./binfrom the pin intools/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.What
tools/generate-noticesdoes, in order:goandgo-licenses, preferring./bin/go-licensesover$PATHso the pinned version is the one that runs.DOCKER_BUILD_PLATFORM_OPTIONSfrommulti-arch.mkand fails if the script'sPLATFORMSarray has drifted from the platforms the image is actually released for.linux/amd64andlinux/arm64, withGOFLAGS=-mod=vendorandCGO_ENABLED=0: runsgo-licenses saveto copy every license file into.licenses-cache, andgo-licenses csvto record the classifications. Results are unioned across platforms. Only the local module is passed to--ignore— go-licenses excludes the standard library itself, and--ignorematches raw string prefixes, so anything shorter and more generic silently drops real dependencies.tools/go.mod, over four platforms including darwin. The tool list is read out oftools/tools.gorather than duplicated, so it cannot drift from whatmake install-toolsbuilds.vendor/modules.txt, giving the exactmodule@version.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=Csorting 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, rungo mod vendor, thenmake noticesand 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.yamltriggers onrelease: 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-checkalready proves the committed file matches the tree. The workflow followsrelease-image-list.yaml, which attaches a generated artifact the same way. It also acceptsworkflow_dispatchwith 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 (240sha256-<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-licensesresolves 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-noticesruns 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 frommulti-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-licensesemits one row per recognized license. Collapsing on the package field kept exactly one:filepath-securejoinshowed as BSD-3-Clause with its MPL-2.0 disclosure dropped, andklauspost/compress,sigs.k8s.io/yamlandsigs.k8s.io/jsoneach lost two. Which row survived was also unstable —sort -uwith 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 modego-licensesreports a URL into this repository atHEAD, which stops describing released content oncemainadvances and names our vendor copy rather than upstream.vendor/modules.txthas 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 setmake cmdsbuilds —CMDSis a wildcard over./cmd/*, and the Dockerfile builds the image by runningmake cmds— so it tracks the build definition rather than a hand-maintained list. Measured against./..., the only difference isgithub.com/onsi/ginkgo/v2andgithub.com/onsi/gomega/formatfrom 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-checkjob is wired intoci.yamland 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.gofiles change their imports, not only whengo.modorvendor/move — a filter keyed on dependency manifests passes green while the committed file goes stale, and the failure then surfaces onmaininstead of on the PR that caused it. Its base computation was also broken:git fetch --depth=1on an already-full clone writes.git/shallow, after whichgit merge-basefinds 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-checkalso fails if the file is untracked, sincegit diffreports 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.)
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.go list -deps ./cmd/...for both released platforms, mapped to license-owning directories: 124 expected, 124 present, 0 missing, 0 extra.Unknownlicenses, zero "License text unavailable", zero unresolved module versions.filepath-securejoin(BSD-3-Clause / MPL-2.0),klauspost/compress,sigs.k8s.io/yaml,sigs.k8s.io/json.make notices-checkpasses against the committed file, and was observed failing correctly when the file was stale and when it is untracked.multi-arch.mk(platform matrix guard) — both exit 1 with actionable messages.shellcheckclean on the generator;actionlintandyamllintclean on both workflows.replacedirective invendor/modules.txt(uses the replacement), a filesystemreplace(fails loudly), and an unreadable module list (fails loudly rather than labelling every rowunknown).Bugs found and fixed during review
Three defects were found by review and testing after the initial version, all now fixed and verified:
google.golang.org/protobuf, all ninegolang.org/x/*,go.uber.org/zapandgopkg.in/yaml.v3. The stdlib ignore list was built withgo list std | cut -d/ -f1, which yields the bare tokengo(fromgo/ast,go/build).go-licensesmatches--ignorewithstrings.HasPrefixon the import path rather than by path segment, sogosilently 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.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/awkon stock Debian and Ubuntu; BSD awk and gawk both behave the other way, which is why it never showed up locally. This would have failednotices-checkon the first CI run.LC_ALL=C, so under a Turkish localeLICENSEstops matching and the document silently collapses from 1.4 MB to 57 KB of "License text unavailable". The fence-widthgreplacked-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.replacedirectives invendor/modules.txtwere misattributed to the original module path and version rather than the code actually vendored, and an unreadable module list labelled every entryunknownwithout failing../cmd/..., so it changes when ordinary.gofiles change imports — which the filter did not watch. Its base computation was also broken:git fetch --depth=1on an already-full clone writes.git/shallow, after whichgit merge-basefinds no common ancestor and the step dies with exit 128; and it diffed againstmaineven for cherry-pick PRs targetingrelease-*. The filter is removed and the check now runs on every build.