diff --git a/.github/scripts/dep_upgrade/baseline_stem.py b/.github/scripts/dep_upgrade/baseline_stem.py new file mode 100755 index 000000000000..fe2d47f39e02 --- /dev/null +++ b/.github/scripts/dep_upgrade/baseline_stem.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Copyright (c) 2024-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +"""Print the baseline_sbom stem a capture just recorded for an image:tag. + +Reads the stem back out of the manifest rather than scraping capture logs. +container/context.yaml holds a single stem per framework and the licenses stage +appends -${TARGETARCH}, so per-arch stems that disagree are unrepresentable +downstream; this fails instead of shipping a half-right baseline. + +Stdout: the bare stem, e.g. release@2366e4b4. +""" +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +DEFAULT_MANIFEST = Path("container/compliance/base_sboms/manifest.json") + + +def stem_for(manifest: dict, image: str, tag: str, arches: list[str]) -> str: + """Return the shared baseline_sbom stem for image:tag across `arches`. + + Raises SystemExit if any arch is missing, matches more than one row, has an + unexpected filename, or if the arches disagree on the stem. + """ + if not arches: + raise SystemExit("no architectures given; --arches must name at least one") + entries = manifest.get("entries", []) + stems: dict[str, str] = {} + for arch in arches: + rows = [ + e + for e in entries + if e.get("from_image") == image + and e.get("from_tag") == tag + and e.get("platform") == f"linux/{arch}" + ] + if len(rows) != 1: + raise SystemExit( + f"expected exactly 1 {arch} row for {image}:{tag}, found {len(rows)}" + ) + suffix = f"-{arch}.cdx.json" + name = rows[0].get("baseline_sbom", "") + if not name.endswith(suffix): + raise SystemExit(f"{arch} baseline_sbom {name!r} does not end in {suffix}") + stems[arch] = name[: -len(suffix)] + if len(set(stems.values())) != 1: + raise SystemExit(f"per-arch baseline stems disagree: {stems}") + return next(iter(stems.values())) + + +def main() -> int: + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--image", required=True, help="from_image, e.g. nvcr.io/nvidia/x") + p.add_argument("--tag", required=True, help="from_tag, e.g. 1.3.0rc25") + p.add_argument("--manifest", type=Path, default=DEFAULT_MANIFEST) + p.add_argument( + "--arches", + default="amd64,arm64", + help="comma-separated arches that must agree (default: %(default)s)", + ) + args = p.parse_args() + + manifest = json.loads(args.manifest.read_text(encoding="utf-8")) + arches = [a.strip() for a in args.arches.split(",") if a.strip()] + print(stem_for(manifest, args.image, args.tag, arches)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/scripts/dep_upgrade/bump_dependency.py b/.github/scripts/dep_upgrade/bump_dependency.py index b9d7693185d8..8efa7a11a8d8 100755 --- a/.github/scripts/dep_upgrade/bump_dependency.py +++ b/.github/scripts/dep_upgrade/bump_dependency.py @@ -27,7 +27,7 @@ # Match runtime_image_tag inside the trtllm: block (first sub-block, # e.g. cuda13.1) without colliding with vllm/sglang earlier in the file. re.compile( - r"(?m)(^trtllm:\s*?\n(?:[ \t]+[^\n]*\n)*?[ \t]+runtime_image_tag:\s+)\S+", + r"(?m)(^trtllm:\s*?\n(?:[ \t][^\n]*\n)*?[ \t]+runtime_image_tag:\s+)\S+", ), r"\g<1>{ver}", ), @@ -51,6 +51,41 @@ "trtllm": TRTLLM_TARGETS, } +# Sibling of FRAMEWORK_TARGETS, kept separate because it is driven by a value +# only the compliance capture can produce (the new baseline's digest stem), not +# by the release version. Same block anchoring as runtime_image_tag above, so it +# cannot reach the vllm/sglang baseline_sbom lines earlier in the file. +FRAMEWORK_BASELINES: dict[str, tuple[str, Pattern[str], str]] = { + "trtllm": ( + "container/context.yaml", + re.compile( + r"(?m)(^trtllm:\s*?\n(?:[ \t][^\n]*\n)*?[ \t]+baseline_sbom:\s+)\S+", + ), + r"\g<1>{stem}", + ), +} + + +def set_baseline_stem(framework: str, stem: str, repo_root: Path) -> bool: + """Point the framework's baseline_sbom at a freshly captured stem. + + The runtime image's licenses stage subtracts this baseline from NOTICES, so + leaving it behind a runtime_image_tag bump makes every moved package version + read as net-new and fails the license gate. Returns True if the file changed. + """ + rel, pat, tmpl = FRAMEWORK_BASELINES[framework] + path = repo_root / rel + text = path.read_text() + new_text, n = pat.subn(tmpl.replace("{stem}", stem), text) + if n != 1: + raise SystemExit( + f"{rel}: matched {n} {framework} baseline_sbom pins; expected 1" + ) + if new_text == text: + return False + path.write_text(new_text) + return True + def apply(framework: str, version: str, repo_root: Path) -> int: """Apply all bumps for the framework. Returns count of files written.""" @@ -81,10 +116,24 @@ def main() -> int: required=True, help="version without 'v' prefix, e.g. 1.3.0rc12", ) + p.add_argument( + "--baseline-stem", + help=( + "Compliance baseline stem from capture_baseline_sbom.py, e.g. " + "release@2366e4b4. When given, also repoints the framework's " + "baseline_sbom in container/context.yaml. Omit to bump versions only." + ), + ) p.add_argument("--repo-root", type=Path, default=Path(".")) args = p.parse_args() n = apply(args.framework, args.version, args.repo_root) print(f"changed {n} files") + if args.baseline_stem: + changed = set_baseline_stem(args.framework, args.baseline_stem, args.repo_root) + print( + f"baseline_sbom -> {args.baseline_stem}" + f"{'' if changed else ' (already set)'}" + ) return 0 diff --git a/.github/scripts/dep_upgrade/check_bump_dependency.py b/.github/scripts/dep_upgrade/check_bump_dependency.py index 920cea352ece..f6aa5556664b 100755 --- a/.github/scripts/dep_upgrade/check_bump_dependency.py +++ b/.github/scripts/dep_upgrade/check_bump_dependency.py @@ -6,7 +6,12 @@ version and asserts it rewrites exactly MAIN_TOT.trtllm in docs/fern/components/releases.data.ts โ€” one line, inside the MAIN_TOT block, no RELEASES pin touched, and the result still parses through -gen_llms_tables.py. Run from anywhere; exit 0 == safe to bump. +gen_llms_tables.py. + +Then runs set_baseline_stem with a synthetic stem and asserts it rewrites +exactly the baseline_sbom inside the trtllm: block of container/context.yaml, +leaving runtime_image_tag and the vllm/sglang blocks alone. Run from anywhere; +exit 0 == safe to bump. """ from __future__ import annotations @@ -14,6 +19,7 @@ import shutil import sys import tempfile +import traceback from pathlib import Path REPO = Path(__file__).resolve().parents[3] @@ -25,6 +31,8 @@ SEED_FILES = ["container/context.yaml", "pyproject.toml", DATA_REL] SYNTH = "9.9.9rc99" # synthetic upstream TRT-LLM version, absent from real data +SYNTH_STEM = "release@0bad0bad" # synthetic baseline stem, absent from real data +CTX_REL = "container/context.yaml" def load(name: str, path: Path): @@ -35,10 +43,21 @@ def load(name: str, path: Path): def fail(msg: str) -> None: + """Report a failed assertion and exit non-zero (exit 0 == safe to bump).""" print(f"FAIL: {msg}") sys.exit(1) +def fail_exc(what: str, exc: BaseException) -> None: + """Same, for an unexpected crash: keep the traceback for debugging. + + The traceback is printed rather than propagated so the script keeps its + single contract -- exit 0 means safe to bump, anything else means stop. + """ + traceback.print_exc() + fail(f"{what} raised {type(exc).__name__}: {exc}") + + def main() -> int: bump = load("bump_dependency", BUMP) gen = load("gen_llms_tables", GEN) @@ -62,7 +81,7 @@ def main() -> int: except SystemExit as exc: fail(f"bump apply() raised SystemExit: {exc}") except Exception as exc: # noqa: BLE001 - any crash is a red result - fail(f"bump apply() raised {type(exc).__name__}: {exc}") + fail_exc("bump apply()", exc) after = data_path.read_text() if after == before: @@ -116,9 +135,69 @@ def main() -> int: if not rel_pins: fail("no RELEASES trtllm pins parsed; cross-check is meaningless") + # --- baseline stem rewrite ------------------------------------------ + # Runs against the already-bumped tree, matching the workflow's order. + ctx_path = root / CTX_REL + ctx_before = ctx_path.read_text() + + try: + if not bump.set_baseline_stem("trtllm", SYNTH_STEM, root): + fail("set_baseline_stem reported no change") + except SystemExit as exc: + fail(f"set_baseline_stem raised SystemExit: {exc}") + except Exception as exc: # noqa: BLE001 - any crash is a red result + fail_exc("set_baseline_stem", exc) + + ctx_after = ctx_path.read_text() + cb, ca = ctx_before.splitlines(), ctx_after.splitlines() + if len(cb) != len(ca): + fail("context.yaml line count changed; expected an in-place stem edit") + ctx_changed = [i for i, (x, y) in enumerate(zip(cb, ca)) if x != y] + if len(ctx_changed) != 1: + fail( + f"expected exactly 1 changed context.yaml line, " + f"got {len(ctx_changed)}: {ctx_changed}" + ) + stem_idx = ctx_changed[0] + if ca[stem_idx].strip() != f"baseline_sbom: {SYNTH_STEM}": + fail(f"changed line is not the baseline_sbom pin: {ca[stem_idx]!r}") + + # It must sit inside the trtllm: block, not vllm/sglang earlier in the file. + fw_start = next( + (i for i, ln in enumerate(ca) if ln.rstrip() == "trtllm:"), None + ) + if fw_start is None: + fail("no top-level 'trtllm:' block found in context.yaml") + fw_end = next( + ( + i + for i in range(fw_start + 1, len(ca)) + if ca[i][:1].strip() and not ca[i].startswith("#") + ), + len(ca), + ) + if not (fw_start < stem_idx < fw_end): + fail( + f"baseline_sbom line {stem_idx} is outside the trtllm: block " + f"({fw_start}..{fw_end})" + ) + + # The version bump and the stem must not clobber each other. + if f"runtime_image_tag: {SYNTH}" not in ctx_after: + fail("runtime_image_tag lost its bumped value after the stem rewrite") + if ctx_after.count(SYNTH_STEM) != 1: + fail( + f"{SYNTH_STEM} appears {ctx_after.count(SYNTH_STEM)} times; expected 1" + ) + + # Idempotent: re-applying the same stem is a no-op, not a second edit. + if bump.set_baseline_stem("trtllm", SYNTH_STEM, root): + fail("set_baseline_stem is not idempotent") + print("PASS: bump rewrote only MAIN_TOT.trtllm; RELEASES pins intact; file parses") print(f" MAIN_TOT.trtllm -> {SYNTH}") print(f" {len(rel_pins)} RELEASES trtllm pins left untouched") + print(f"PASS: baseline_sbom -> {SYNTH_STEM} (trtllm block only, idempotent)") return 0 diff --git a/.github/scripts/dep_upgrade/detect_latest_versions.py b/.github/scripts/dep_upgrade/detect_latest_versions.py index 2be0d669eb8c..41b5372ff367 100755 --- a/.github/scripts/dep_upgrade/detect_latest_versions.py +++ b/.github/scripts/dep_upgrade/detect_latest_versions.py @@ -3,7 +3,10 @@ # SPDX-License-Identifier: Apache-2.0 """Detect latest upstream version for a Dynamo framework dependency. -Stdout: one-line JSON: {"framework", "current", "latest", "upgrade_needed"}. +Stdout: one-line JSON: +{"framework", "current", "latest", "upgrade_needed", "runtime_image"}. +runtime_image is the registry repo the tag belongs to, so callers can build the +full ref (:) without re-parsing context.yaml. Exit 0 on success regardless of upgrade_needed; non-zero only on real errors. """ from __future__ import annotations @@ -24,7 +27,11 @@ # Anchored inside the trtllm: block so we don't pick up # vllm/sglang runtime_image_tag lines earlier in the file. "current_regex": re.compile( - r"(?m)^trtllm:\s*?\n(?:[ \t]+[^\n]*\n)*?[ \t]+runtime_image_tag:\s*(\S+)\s*$", + r"(?m)^trtllm:\s*?\n(?:[ \t][^\n]*\n)*?[ \t]+runtime_image_tag:\s*(\S+)\s*$", + ), + # Same anchoring for the sibling runtime_image the tag hangs off. + "runtime_image_regex": re.compile( + r"(?m)^trtllm:\s*?\n(?:[ \t][^\n]*\n)*?[ \t]+runtime_image:\s*(\S+)\s*$", ), }, } @@ -75,15 +82,34 @@ def latest_release(repo: str, include_prereleases: bool) -> str: return max(candidates, key=parse_version) -def current_pin(framework: str, repo_root: Path) -> str: +def _context_field(framework: str, key: str, repo_root: Path) -> str: + """Return the single capture of FRAMEWORK_SOURCES[framework][key] in context.yaml. + + Raises SystemExit if the pattern does not match, so a drifted pin format + fails loudly rather than silently reporting a stale value. + """ src = FRAMEWORK_SOURCES[framework] text = (repo_root / "container" / "context.yaml").read_text() - m = src["current_regex"].search(text) # type: ignore[union-attr] + m = src[key].search(text) # type: ignore[union-attr] if not m: - raise SystemExit(f"no current pin found for {framework}") + raise SystemExit(f"no {key} match for {framework} in container/context.yaml") return m.group(1) +def current_pin(framework: str, repo_root: Path) -> str: + """Return the framework's currently pinned version, e.g. 1.3.0rc25.""" + return _context_field(framework, "current_regex", repo_root) + + +def runtime_image(framework: str, repo_root: Path) -> str: + """Return the registry repo the pin belongs to, without a tag. + + e.g. nvcr.io/nvidia/tensorrt-llm/release. Callers join this with the + detected version to form the ref to capture a baseline against. + """ + return _context_field(framework, "runtime_image_regex", repo_root) + + def main() -> int: p = argparse.ArgumentParser(description=__doc__) p.add_argument("--framework", required=True, choices=sorted(FRAMEWORK_SOURCES)) @@ -105,6 +131,7 @@ def main() -> int: "current": cur, "latest": latest, "upgrade_needed": parse_version(latest) > parse_version(cur), + "runtime_image": runtime_image(args.framework, args.repo_root), } ) ) diff --git a/.github/workflows/auto-dep-upgrade-trigger.yml b/.github/workflows/auto-dep-upgrade-trigger.yml index 4c5ead538553..13101beeb513 100644 --- a/.github/workflows/auto-dep-upgrade-trigger.yml +++ b/.github/workflows/auto-dep-upgrade-trigger.yml @@ -1,5 +1,12 @@ # SPDX-FileCopyrightText: Copyright (c) 2024-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 +# +# Bumping runtime_image_tag moves the runtime image out from under the +# baseline_sbom that the licenses stage subtracts from NOTICES. Subtraction +# keys on (name, version), so every package whose version moved in the new tag +# stops subtracting, reads as net-new, resolves to UNKNOWN, and fails the +# license gate. The baseline is therefore re-captured here, in the same commit +# as the version bump, and a capture that cannot complete pushes nothing. name: Auto Dependency Upgrade - Trigger @@ -13,26 +20,60 @@ on: type: choice options: [trtllm] default: trtllm + dry_run: + description: 'Rehearse the baseline capture only; push nothing' + type: boolean + default: false permissions: contents: write actions: write +# A capture holds a scarce runner for tens of minutes, so never run two at once +# for the same ref. Queue rather than cancel: a half-finished capture wastes the +# scan it already paid for, and the branch idempotency check makes the second +# run cheap anyway. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + jobs: - upgrade: - name: ${{ matrix.framework }} + # The matrix is single-entry, which is what makes the job outputs below + # well-defined: a matrix job's outputs are last-writer-wins across instances. + # Adding a second framework means extracting these three jobs into a + # workflow_call file and running the matrix over that instead. + detect: + name: ${{ matrix.framework }} / detect runs-on: ubuntu-latest + permissions: + contents: read strategy: fail-fast: false matrix: framework: [trtllm] + outputs: + framework: ${{ matrix.framework }} + current: ${{ steps.detect.outputs.current }} + latest: ${{ steps.detect.outputs.latest }} + runtime_image: ${{ steps.detect.outputs.runtime_image }} + branch: ${{ steps.branch.outputs.name }} + proceed: ${{ steps.branch.outputs.proceed }} steps: - - name: Checkout main + # No explicit ref: checkout defaults to the ref that triggered the run. + # A scheduled run is always the default branch, so cron behaviour is + # unchanged, while a workflow_dispatch runs the pipeline code from the ref + # you dispatched -- which is what makes a change to it testable pre-merge. + # + # Because that ref is caller-chosen, this job must hold no write-capable + # credential: it runs repository Python, so persisting the bot PAT here + # would let anyone able to dispatch escalate to whatever that PAT can + # reach. Nothing here needs it -- the release lookup uses GITHUB_TOKEN and + # ls-remote only reads. + - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - ref: main fetch-depth: 0 - token: ${{ secrets.OPS_BOT_PAT }} + persist-credentials: false - name: Set up Python uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 @@ -51,55 +92,197 @@ jobs: echo "current=$(echo "$OUT" | jq -r .current)" echo "latest=$(echo "$OUT" | jq -r .latest)" echo "upgrade_needed=$(echo "$OUT" | jq -r .upgrade_needed)" + echo "runtime_image=$(echo "$OUT" | jq -r .runtime_image)" } >> "$GITHUB_OUTPUT" - - name: Already up to date - if: steps.detect.outputs.upgrade_needed != 'true' - run: echo "No upgrade needed (current=${{ steps.detect.outputs.current }}, latest=${{ steps.detect.outputs.latest }})." - - name: Compute branch name + idempotency check - if: steps.detect.outputs.upgrade_needed == 'true' id: branch + env: + UPGRADE_NEEDED: ${{ steps.detect.outputs.upgrade_needed }} run: | BRANCH="deps/upgrade-${{ matrix.framework }}-v${{ steps.detect.outputs.latest }}" echo "name=$BRANCH" >> "$GITHUB_OUTPUT" - if git ls-remote --heads origin "$BRANCH" | grep -q "$BRANCH"; then + if [ "$UPGRADE_NEEDED" != "true" ]; then + echo "No upgrade needed (current=${{ steps.detect.outputs.current }}, latest=${{ steps.detect.outputs.latest }})." + echo "proceed=false" >> "$GITHUB_OUTPUT" + elif git ls-remote --heads origin "$BRANCH" | grep -q "$BRANCH"; then echo "Branch $BRANCH already exists on origin; skipping." - echo "exists=true" >> "$GITHUB_OUTPUT" + echo "proceed=false" >> "$GITHUB_OUTPUT" else - echo "exists=false" >> "$GITHUB_OUTPUT" + echo "proceed=true" >> "$GITHUB_OUTPUT" + fi + + # syft catalogs the full multi-GB runtime image. Runner history is recorded in + # shared-compliance-audit.yml: small-v2 and prod-default-v2 run out of disk and + # prod-builder-v3 OOMs, so this pool -- which runs these images for tests -- is + # the one with the headroom. Both architectures are captured from here: syft + # selects the platform via SYFT_PLATFORM and scans the registry, so the arm64 + # pass needs neither an arm64 runner nor emulation. + capture: + name: ${{ needs.detect.outputs.framework }} / baseline capture + needs: detect + if: needs.detect.outputs.proceed == 'true' + # Fastchecker rather than prod-tester-amd-v2: that pool forbids containerless + # jobs and its admission rejects a push on a personal branch, so the pipeline + # could never be exercised before merge. Fastchecker runs the rust-tests / + # rust-clippy jobs containerless and admits this event, which keeps a change + # to this workflow testable. + runs-on: + group: Fastchecker + timeout-minutes: 90 + permissions: + contents: read + outputs: + stem: ${{ steps.capture.outputs.stem }} + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Install tooling + run: | + set -euo pipefail + docker buildx version + # container/compliance/overrides.py imports yaml. + python3 -c 'import yaml' 2>/dev/null \ + || python3 -m pip install --quiet --user --break-system-packages pyyaml \ + || python3 -m pip install --quiet --user pyyaml + mkdir -p "${RUNNER_TEMP}/bin" + curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh \ + | sh -s -- -b "${RUNNER_TEMP}/bin" + echo "${RUNNER_TEMP}/bin" >> "$GITHUB_PATH" + + - name: Capture baseline SBOMs + id: capture + env: + REF: ${{ needs.detect.outputs.runtime_image }}:${{ needs.detect.outputs.latest }} + DRY_RUN: ${{ inputs.dry_run }} + run: | + set -euo pipefail + EXTRA=() + if [ "${DRY_RUN:-false}" = "true" ]; then EXTRA=(--dry-run); fi + # Sequential, not parallel: both passes read-modify-write the single + # manifest.json, so concurrent runs would drop one arch's entry. + for PLATFORM in linux/amd64 linux/arm64; do + echo "::group::capture ${PLATFORM}" + python3 container/compliance/base_sboms/capture_baseline_sbom.py \ + --from "$REF" --baseline "$REF" \ + --platform "$PLATFORM" --prune-superseded -v "${EXTRA[@]}" + echo "::endgroup::" + done + if [ "${DRY_RUN:-false}" = "true" ]; then + echo "Dry run: manifest not written, no stem to publish." + exit 0 fi + STEM=$(python3 .github/scripts/dep_upgrade/baseline_stem.py \ + --image "${REF%:*}" --tag "${REF##*:}") + echo "Captured baseline stem: $STEM" + echo "stem=$STEM" >> "$GITHUB_OUTPUT" + + - name: Upload refreshed baseline corpus + if: inputs.dry_run != true + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: baseline-refresh-${{ needs.detect.outputs.framework }}-${{ needs.detect.outputs.latest }} + path: | + container/compliance/base_sboms/manifest.json + container/compliance/base_sboms/*.cdx.json + if-no-files-found: error + retention-days: 7 + + - name: Notify on capture failure + # A failed capture pushes no branch and opens no PR, so without this the + # upgrade stalls silently -- a genuinely disallowed license in a new tag + # would go unnoticed until someone wonders why bumps stopped arriving. + if: failure() + uses: slackapi/slack-github-action@91efab103c0de0a537f72a35f6b8cda0ee76bf0a # v2.1.1 + with: + webhook: ${{ secrets.SLACK_NOTIFY_NIGHTLY_WEBHOOK_URL }} + webhook-type: incoming-webhook + payload: | + blocks: + - type: "section" + text: + type: mrkdwn + text: ":package: *Auto Dep Upgrade* :x: Baseline capture failed โ€” ${{ needs.detect.outputs.framework }} v${{ needs.detect.outputs.latest }}" + - type: "section" + text: + type: mrkdwn + text: "<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|Workflow run> ยท image `${{ needs.detect.outputs.runtime_image }}:${{ needs.detect.outputs.latest }}`" + - type: "section" + text: + type: mrkdwn + text: "No branch was pushed. Common causes: the container tag is not published yet (the cron will retry), or the new image introduces a license the policy denies (needs a human)." + + commit: + name: ${{ needs.detect.outputs.framework }} / commit + needs: [detect, capture] + if: needs.detect.outputs.proceed == 'true' && inputs.dry_run != true + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + token: ${{ secrets.OPS_BOT_PAT }} + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: '3.12' + + - name: Clear stale baseline SBOMs + # Artifacts carry additions, not deletions, so without this the SBOMs + # that --prune-superseded removed would survive into the commit. + run: rm -f container/compliance/base_sboms/*.cdx.json + + - name: Download refreshed baseline corpus + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: baseline-refresh-${{ needs.detect.outputs.framework }}-${{ needs.detect.outputs.latest }} + path: container/compliance/base_sboms - name: Apply bump - if: steps.detect.outputs.upgrade_needed == 'true' && steps.branch.outputs.exists == 'false' + env: + FW: ${{ needs.detect.outputs.framework }} + VER: ${{ needs.detect.outputs.latest }} + STEM: ${{ needs.capture.outputs.stem }} run: | - # Pre-flight: prove the bump tooling still edits exactly the - # MAIN_TOT pin in releases.data.ts before touching the real tree. + set -euo pipefail + test -n "$STEM" + # Pre-flight: prove the bump tooling still edits exactly the MAIN_TOT + # pin in releases.data.ts and the trtllm baseline_sbom in context.yaml + # before touching the real tree. python3 .github/scripts/dep_upgrade/check_bump_dependency.py python3 .github/scripts/dep_upgrade/bump_dependency.py \ - --framework ${{ matrix.framework }} \ - --version ${{ steps.detect.outputs.latest }} + --framework "$FW" --version "$VER" --baseline-stem "$STEM" git status --short - name: Commit + push upgrade branch - if: steps.detect.outputs.upgrade_needed == 'true' && steps.branch.outputs.exists == 'false' + env: + FW: ${{ needs.detect.outputs.framework }} + VER: ${{ needs.detect.outputs.latest }} + BRANCH: ${{ needs.detect.outputs.branch }} run: | + set -euo pipefail git config user.name "dynamo-ops" git config user.email "170655669+dynamo-ops@users.noreply.github.com" - git checkout -b "${{ steps.branch.outputs.name }}" + git checkout -b "$BRANCH" # releases.data.ts feeds generated llms-tables twins and JSON/Atom assets; # regenerate them in the same commit or check_reference.sh flags the branch stale. python3 docs/fern/scripts/gen_llms_tables.py git add container/context.yaml pyproject.toml docs/fern/components/releases.data.ts git add docs/fern/ - git commit -s -m "chore: bump ${{ matrix.framework }} to v${{ steps.detect.outputs.latest }}" - git push origin "${{ steps.branch.outputs.name }}" + # -A so the pruned SBOMs are staged as deletions, not left behind. + git add -A container/compliance/base_sboms/ + git commit -s -m "chore: bump $FW to v$VER" + git push origin "$BRANCH" - name: Dispatch post-merge-ci on upgrade branch - if: steps.detect.outputs.upgrade_needed == 'true' && steps.branch.outputs.exists == 'false' env: GH_TOKEN: ${{ secrets.OPS_BOT_PAT }} + FW: ${{ needs.detect.outputs.framework }} + BRANCH: ${{ needs.detect.outputs.branch }} run: | - gh workflow run post-merge-ci.yml \ - --ref "${{ steps.branch.outputs.name }}" \ - -f framework=${{ matrix.framework }} + gh workflow run post-merge-ci.yml --ref "$BRANCH" -f framework="$FW" diff --git a/container/compliance/README.md b/container/compliance/README.md index 18dd0a12582e..9a134d69bd03 100644 --- a/container/compliance/README.md +++ b/container/compliance/README.md @@ -60,6 +60,15 @@ PR and on a daily cron (`.github/workflows/compliance-base-drift.yml`); it fails digest moved or the layer-prefix invariant no longer holds, which means a vendor silently switched a base image and the corpus must be re-captured. +A tag bump is the other way a baseline goes stale, and the upsert key includes `from_tag`, so +re-capturing on a new tag appends rather than replaces. Pass `--prune-superseded` to drop the +old tag's rows and the SBOM files nothing references any more; leaving them behind eventually +fails the drift check, which re-resolves every row against the registry. + +For TRT-LLM this is automated: `.github/workflows/auto-dep-upgrade-trigger.yml` re-captures both +architectures against the new tag and commits the refreshed corpus alongside the version bump, so +`runtime_image_tag` and `baseline_sbom` never diverge. A capture that fails pushes nothing. + ## CI integration - **Inline extraction** โ€” `.github/actions/compliance-extract` extracts `/legal` + `/sboms` diff --git a/container/compliance/base_sboms/capture_baseline_sbom.py b/container/compliance/base_sboms/capture_baseline_sbom.py index 3b8728429c39..cb60e16ef724 100755 --- a/container/compliance/base_sboms/capture_baseline_sbom.py +++ b/container/compliance/base_sboms/capture_baseline_sbom.py @@ -36,7 +36,8 @@ 5. syft scan the from_image (in memory; not persisted). Compute delta components = from.components - baseline.components by (name, version). This is what we'd redistribute on top of the - baseline. + baseline. Skipped when both refs resolve to the same digest: the + delta is empty by construction, so the baseline scan is reused. 6. Run policy/validate.py against the delta. Any denied or UNKNOWN license fails the capture: engineer must add an override / exception, or pick a different from_image. @@ -51,6 +52,11 @@ --skip-layer-prefix-check Escape hatch for vendors who squash layers. Records the override in the manifest entry for auditability. Use only with justification. + --prune-superseded Drop rows for the same image+platform on an + older tag, and delete the SBOM files nothing + references any more. The upsert key includes + from_tag, so without this a tag bump appends + and leaves the previous tag's rows behind. Dependencies on the runner: - docker buildx (registry manifest resolution) @@ -139,7 +145,7 @@ def resolve_platform_layers(ref: str, platform: str) -> list[str]: p = entry.get("platform", {}) or {} if p.get("os") == os_part and p.get("architecture") == arch_part: # Fetch this platform's specific manifest. - repo = ref.rsplit(":", 1)[0] + repo = ref.split("@", 1)[0] if "@" in ref else ref.rsplit(":", 1)[0] platform_ref = f"{repo}@{entry['digest']}" manifest_raw = _imagetools_raw(platform_ref) manifest = json.loads(manifest_raw) @@ -506,8 +512,12 @@ def validate_delta(delta: list[dict], policy_path: Path) -> tuple[int, list[str] def _short_name(image: str) -> str: - """nvcr.io/nvidia/cuda-dl-base -> cuda-dl-base""" - return image.rsplit("/", 1)[-1] + """nvcr.io/nvidia/cuda-dl-base -> cuda-dl-base + + Digest-pinned refs reach here as "@sha256" (split_ref keeps the + ref round-trippable as f"{image}:{tag}"), so drop that suffix too. + """ + return image.rsplit("/", 1)[-1].split("@", 1)[0] def load_manifest(path: Path) -> dict: @@ -537,6 +547,62 @@ def upsert_entry(manifest: dict, entry: dict) -> None: entries.append(entry) +def prune_superseded( + manifest: dict, entry: dict, corpus_dir: Path, dry_run: bool = False +) -> list[str]: + """Drop rows for `entry`'s (from_image, platform) that sit on an older tag. + + upsert_entry keys on from_tag, so a tag bump appends instead of replacing and + the previous tag's rows survive. check_drift.py re-resolves every row against + the registry and counts a lookup failure as drift, so a superseded tag turns + the drift check red as soon as the vendor garbage-collects it. + + Returns the SBOM filenames removed from the corpus. + """ + entries = manifest.setdefault("entries", []) + key = (entry["from_image"], entry["from_tag"], entry["platform"]) + + def _superseded(e: dict) -> bool: + return ( + e.get("from_image") == entry["from_image"] + and e.get("platform") == entry["platform"] + and e.get("from_tag") != entry["from_tag"] + ) + + def _fresh(e: dict) -> bool: + return (e.get("from_image"), e.get("from_tag"), e.get("platform")) == key + + slots = [i for i, e in enumerate(entries) if _superseded(e)] + if not slots: + return [] + + kept = [e for e in entries if not _superseded(e) and not _fresh(e)] + # Reposition the fresh row into the first superseded row's slot so a refresh + # reads as an in-place edit instead of a delete plus an append at the end. + slot = sum(1 for e in entries[: slots[0]] if not _superseded(e) and not _fresh(e)) + kept.insert(slot, entry) + + # Only unlink an SBOM no surviving row still points at: one file commonly + # backs several rows (the cuda and ubuntu baselines each back multiple). + live = {e.get("baseline_sbom") for e in kept} + removed: list[str] = [] + for name in sorted( + {e.get("baseline_sbom") for e in entries if _superseded(e)} - live + ): + if not name: + continue + path = corpus_dir / name + if not path.is_file(): + continue + if not dry_run: + path.unlink() + removed.append(name) + + if not dry_run: + manifest["entries"] = kept + return removed + + # ---- main capture orchestration ---------------------------------------------- @@ -559,6 +625,7 @@ def capture( reuse_cached_sbom: bool = False, from_sbom_cache_dir: Path | None = None, no_from_sbom_cache: bool = False, + prune_superseded_entries: bool = False, ) -> int: from_image, from_tag = split_ref(from_ref) baseline_image, baseline_tag = split_ref(baseline_ref) @@ -658,7 +725,16 @@ def capture( # back-to-back invocations of this script, which is the slow path we # care about when iterating on overrides/exceptions. from_sbom = None - if not no_from_sbom_cache and from_cache_path.is_file(): + if from_digest == baseline_digest: + # Same manifest on both sides (typical when the baseline IS the image + # we FROM), so the delta is empty by construction. Reuse the baseline + # scan rather than syft-ing the same image a second time. + logger.info( + "from-image and baseline resolve to the same digest; reusing the " + "baseline SBOM instead of re-scanning" + ) + from_sbom = baseline_sbom + if from_sbom is None and not no_from_sbom_cache and from_cache_path.is_file(): try: from_sbom = json.loads(from_cache_path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as exc: @@ -770,11 +846,23 @@ def capture( if dry_run: logger.info("--dry-run: not writing %s or manifest", sbom_path) logger.info("manifest entry would be:\n%s", json.dumps(entry, indent=2)) + if prune_superseded_entries: + planned = prune_superseded( + load_manifest(manifest_path), entry, corpus_dir, dry_run=True + ) + logger.info( + "--dry-run: would prune %d superseded SBOM(s): %s", + len(planned), + ", ".join(planned) or "none", + ) return 0 sbom_path.write_bytes(payload_bytes) manifest = load_manifest(manifest_path) upsert_entry(manifest, entry) + if prune_superseded_entries: + for name in prune_superseded(manifest, entry, corpus_dir): + logger.info("Pruned superseded baseline SBOM %s", name) save_manifest(manifest, manifest_path) logger.info("Wrote %s and updated %s", sbom_path, manifest_path) logger.info( @@ -858,6 +946,15 @@ def main(argv: list[str] | None = None) -> int: action="store_true", help="Disable the from-image SBOM cache entirely (do not read or write).", ) + parser.add_argument( + "--prune-superseded", + action="store_true", + help=( + "After upserting, drop manifest rows for the same image+platform on " + "an older tag and delete the SBOM files nothing references any more. " + "Use on a tag bump, where the upsert key never matches the old row." + ), + ) parser.add_argument("-v", "--verbose", action="store_true") args = parser.parse_args(argv) @@ -879,6 +976,7 @@ def main(argv: list[str] | None = None) -> int: reuse_cached_sbom=args.reuse_cached_sbom, from_sbom_cache_dir=args.from_sbom_cache_dir, no_from_sbom_cache=args.no_from_sbom_cache, + prune_superseded_entries=args.prune_superseded, ) except Exception: # pragma: no cover logger.exception("Unhandled error during capture")