From e4765ddca2f3af5b7cdb3e66bde812689800d980 Mon Sep 17 00:00:00 2001 From: Ralf Juengling Date: Tue, 3 Nov 2026 13:57:12 -0800 Subject: [PATCH 1/5] ci: harden fetch_ctk with manifest/archive verification and redist-base-url Brings the public fetch_ctk action and redistrib helper into parity with the private ctk-next branch. fetch_ctk/action.yml - Add optional redist-base-url input (default: public NVIDIA developer server). Callers can override to install from an alternate redistrib server without changing the action itself. - Split the single 'Install dependencies' step into two OS-gated steps: 'Install Unix dependencies' (Linux) and 'Install Windows dependencies' (Windows, via the new install_windows_deps action). This makes the action usable on Windows runners. - Download the redistrib manifest once in 'Set up CTK cache variable' and reuse it in 'Get CUDA components', eliminating a redundant network round-trip. - Verify the manifest hash before trusting it (verify-metadata). Public- server manifests are checked against KNOWN_REDISTRIB_SHA256; alternate servers warn only. - Include the first 8 hex digits of the manifest sha256 in the cache key (-m). Alternate servers can publish different manifests under the same CUDA version; without this they would restore each other's cached toolkit. - Verify each downloaded component archive against the sha256 in the (now trusted) manifest before extracting (component-sha256 + sha256sum --check). The old code extracted without any archive check. install_windows_deps/action.yml (new) - Installs a pinned portable Git for Windows bundle (ARM64 or x64) when curl, unzip, tar, sha256sum, or cygpath are not already on PATH. Checks the archive hash before extracting. ci/tools/fetch_ctk_redistrib.py - Add KNOWN_REDISTRIB_SHA256: pinned public manifest hashes for 12.6.3, 12.9.1, 13.0.2, and 13.4.2. Extend as new versions enter the matrix. - Add get_component_subdir_info, get_component_sha256, compute_file_sha256, verify_metadata_sha256. - Add component-sha256 and verify-metadata subcommands. verify-metadata accepts --metadata-source {public,custom}; custom sources only warn (never fail closed on a hash mismatch). - Remove --metadata-url from filter-components and component-relative- path; callers must download the manifest themselves and pass --metadata-path. Removes the urllib dependency. - Simplify load_metadata to read from a local path only. ci/tools/tests/test_fetch_ctk_redistrib.py (new) - 13 agent-authored unit tests covering get_component_sha256 (hash lookup, alias resolution, missing field, unavailable platform), verify_metadata_sha256 (match, mismatch, unpinned warning, custom source), component-sha256 and verify-metadata CLI subcommands. --- .github/actions/fetch_ctk/action.yml | 81 ++++++--- .../actions/install_windows_deps/action.yml | 77 ++++++++ ci/tools/fetch_ctk_redistrib.py | 139 ++++++++++++--- ci/tools/tests/test_fetch_ctk_redistrib.py | 167 ++++++++++++++++++ 4 files changed, 416 insertions(+), 48 deletions(-) create mode 100644 .github/actions/install_windows_deps/action.yml create mode 100644 ci/tools/tests/test_fetch_ctk_redistrib.py diff --git a/.github/actions/fetch_ctk/action.yml b/.github/actions/fetch_ctk/action.yml index 994e35924fc..3beca78fd66 100644 --- a/.github/actions/fetch_ctk/action.yml +++ b/.github/actions/fetch_ctk/action.yml @@ -19,35 +19,72 @@ inputs: description: "where the CTK components will be installed to, relative to $PWD" required: false default: "./cuda_toolkit" + redist-base-url: + description: > + Base URL of a redistrib server (manifest and component archives). + Defaults to the public NVIDIA developer server. + Override to install from an alternate redistrib server. + required: false + default: "https://developer.download.nvidia.com/compute/cuda/redist" runs: using: composite steps: + - name: Install Unix dependencies + if: ${{ runner.os == 'Linux' }} + uses: ./.github/actions/install_unix_deps + with: + dependencies: "zstd curl xz-utils" + dependent_exes: "zstd curl xz" + + - name: Install Windows dependencies + if: ${{ runner.os == 'Windows' }} + uses: ./.github/actions/install_windows_deps + with: + dependent_exes: "curl unzip tar sha256sum cygpath" + - name: Set up CTK cache variable shell: bash --noprofile --norc -xeuo pipefail {0} run: | - # Pre-process the component list to ensure hash uniqueness # Use the runtime workspace mount so this also works inside container jobs. CTK_REDIST_TOOL="${GITHUB_WORKSPACE}/ci/tools/fetch_ctk_redistrib.py" - CTK_CACHE_COMPONENTS=${{ inputs.cuda-components }} - CTK_JSON_URL="https://developer.download.nvidia.com/compute/cuda/redist/redistrib_${{ inputs.cuda-version }}.json" + CTK_BASE_URL="${{ inputs.redist-base-url }}" + # RUNNER_TEMP persists across steps, so "Get CUDA components" reuses this file. + CTK_JSON_FILE="$RUNNER_TEMP/redistrib-${{ inputs.cuda-version }}.json" + curl -LSs "${CTK_BASE_URL}/redistrib_${{ inputs.cuda-version }}.json" -o "$CTK_JSON_FILE" + + # Pin the metadata JSON: the per-archive sha256 checks below are only + # trustworthy if the JSON that carries those hashes is itself trusted. + # The pins describe the public server; alternate servers only warn. + if [[ "$CTK_BASE_URL" == "https://developer.download.nvidia.com/compute/cuda/redist" ]]; then + METADATA_SOURCE=public + else + METADATA_SOURCE=custom + fi + python "$CTK_REDIST_TOOL" verify-metadata \ + --cuda-version "${{ inputs.cuda-version }}" \ + --metadata-path "$CTK_JSON_FILE" \ + --metadata-source "$METADATA_SOURCE" + + # Pre-process the component list to ensure hash uniqueness CTK_CACHE_COMPONENTS="$(python "$CTK_REDIST_TOOL" filter-components \ --host-platform "${{ inputs.host-platform }}" \ --cuda-version "${{ inputs.cuda-version }}" \ - --components "$CTK_CACHE_COMPONENTS" \ - --metadata-url "$CTK_JSON_URL")" + --components "${{ inputs.cuda-components }}" \ + --metadata-path "$CTK_JSON_FILE")" + # Alternate redist servers (non-default) publish different manifests for the + # same CUDA version; keying on the manifest hash keeps them from restoring + # each other's toolkit. + MANIFEST_SHA8="$(sha256sum "$CTK_JSON_FILE" | cut -c1-8)" HASH=$(echo -n "${CTK_CACHE_COMPONENTS}" | sha256sum | awk '{print $1}') - echo "CTK_CACHE_KEY=mini-ctk-${{ inputs.cuda-version }}-${{ inputs.host-platform }}-$HASH" >> $GITHUB_ENV - echo "CTK_CACHE_FILENAME=mini-ctk-${{ inputs.cuda-version }}-${{ inputs.host-platform }}-$HASH.tar.gz" >> $GITHUB_ENV - echo "CTK_CACHE_COMPONENTS=${CTK_CACHE_COMPONENTS}" >> $GITHUB_ENV - - - name: Install dependencies - uses: ./.github/actions/install_unix_deps - continue-on-error: false - with: - dependencies: "zstd curl xz-utils" - dependent_exes: "zstd curl xz" + { + echo "CTK_CACHE_KEY=mini-ctk-${{ inputs.cuda-version }}-${{ inputs.host-platform }}-$HASH-m$MANIFEST_SHA8" + echo "CTK_CACHE_FILENAME=mini-ctk-${{ inputs.cuda-version }}-${{ inputs.host-platform }}-$HASH-m$MANIFEST_SHA8.tar.gz" + echo "CTK_CACHE_COMPONENTS=${CTK_CACHE_COMPONENTS}" + echo "CTK_BASE_URL=${CTK_BASE_URL}" + echo "CTK_JSON_FILE=${CTK_JSON_FILE}" + } >> "$GITHUB_ENV" - name: Download CTK cache id: ctk-get-cache @@ -68,13 +105,10 @@ runs: rm -rf $CACHE_TMP_DIR mkdir $CACHE_TMP_DIR - # The binary archives (redist) are guaranteed to be updated as part of the release posting. # Use the runtime workspace mount so this also works inside container jobs. CTK_REDIST_TOOL="${GITHUB_WORKSPACE}/ci/tools/fetch_ctk_redistrib.py" - CTK_BASE_URL="https://developer.download.nvidia.com/compute/cuda/redist/" - CTK_JSON_URL="$CTK_BASE_URL/redistrib_${{ inputs.cuda-version }}.json" - CTK_JSON_FILE="$CACHE_TMP_DIR/redistrib.json" - curl -LSs "$CTK_JSON_URL" -o "$CTK_JSON_FILE" + # CTK_BASE_URL and the verified manifest at CTK_JSON_FILE come from + # "Set up CTK cache variable". if [[ "${{ inputs.host-platform }}" == linux* ]]; then function extract() { tar -xvf $1 -C $CACHE_TMP_DIR --strip-components=1 @@ -102,6 +136,13 @@ runs: CTK_COMPONENT_URL="${CTK_BASE_URL}/${CTK_COMPONENT_REL_PATH}" CTK_COMPONENT_COMPONENT_FILENAME="$(basename $CTK_COMPONENT_REL_PATH)" download $CTK_COMPONENT_URL $CTK_COMPONENT_COMPONENT_FILENAME + # Verify the downloaded archive against the sha256 in the (pinned) + # metadata before extracting; fail closed on any mismatch. + CTK_COMPONENT_SHA256="$(python "$CTK_REDIST_TOOL" component-sha256 \ + --host-platform "${{ inputs.host-platform }}" \ + --component "$CTK_COMPONENT" \ + --metadata-path "$CTK_JSON_FILE")" + echo "${CTK_COMPONENT_SHA256} ${CTK_COMPONENT_COMPONENT_FILENAME}" | sha256sum --check --strict - extract $CTK_COMPONENT_COMPONENT_FILENAME rm $CTK_COMPONENT_COMPONENT_FILENAME } diff --git a/.github/actions/install_windows_deps/action.yml b/.github/actions/install_windows_deps/action.yml new file mode 100644 index 00000000000..87edabb9dfa --- /dev/null +++ b/.github/actions/install_windows_deps/action.yml @@ -0,0 +1,77 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: Install dependencies on Windows + +description: Install Git Bash tools from a verified portable Git bundle when required executables are missing. + +inputs: + dependent_exes: + description: Space-separated executable names provided by Git for Windows + required: true + +runs: + using: composite + steps: + - name: Install dependencies + shell: powershell + env: + DEPENDENT_EXES: ${{ inputs.dependent_exes }} + run: | + $ErrorActionPreference = 'Stop' + if ($env:DEPENDENT_EXES -notmatch '^\s*[a-zA-Z0-9_.-]+(\s+[a-zA-Z0-9_.-]+)*\s*$') { + throw 'Expected a space-separated list of executable names.' + } + $check = 'missing=0; for tool in $DEPENDENT_EXES; do command -v "$tool" || missing=1; done; exit "$missing"' + $bash = Get-Command bash -ErrorAction SilentlyContinue + if ($bash) { + & $bash.Source --noprofile --norc -c $check + if ($LASTEXITCODE -eq 0) { + Write-Host 'All dependencies are found. Do nothing.' + exit 0 + } + } + + $architecture = [Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() + switch ($architecture) { + 'Arm64' { + $package = 'arm64' + $expectedHash = '3bf26b94d9399b16a890776e468334f501742861576cbcdea2d9134643c374bd' + } + 'X64' { + $package = '64-bit' + $expectedHash = 'ab00566336b5472120f9a52d34f2e79c5406535792acb0548001ffd0bd090e5d' + } + default { throw "Unsupported Windows architecture: $architecture" } + } + $root = Join-Path $env:RUNNER_TOOL_CACHE "WindowsDeps\Git-2.55.0.3-$package" + $complete = "$root.complete" + if (-not (Test-Path -LiteralPath $complete)) { + $archive = Join-Path $env:RUNNER_TEMP "PortableGit-2.55.0.3-$package.7z.exe" + try { + Invoke-WebRequest -UseBasicParsing -Uri "https://github.com/git-for-windows/git/releases/download/v2.55.0.windows.3/PortableGit-2.55.0.3-$package.7z.exe" -OutFile $archive + $actualHash = (Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actualHash -ne $expectedHash) { + throw "Portable Git checksum mismatch: expected $expectedHash, got $actualHash." + } + if (Test-Path -LiteralPath $root) { + Remove-Item -LiteralPath $root -Recurse -Force + } + $process = Start-Process -FilePath $archive -ArgumentList @('-y', ('-o"{0}"' -f $root)) -Wait -PassThru + if ($process.ExitCode -ne 0) { + throw "Portable Git extraction failed with exit code $($process.ExitCode)." + } + } + finally { + if (Test-Path -LiteralPath $archive) { + Remove-Item -LiteralPath $archive -Force + } + } + } + $env:PATH = "$root\bin;$root\usr\bin;$env:PATH" + & "$root\bin\bash.exe" --noprofile --norc -c $check + if ($LASTEXITCODE -ne 0) { + throw 'Required executables are still missing after installing portable Git.' + } + New-Item -ItemType File -Force -Path $complete | Out-Null + @("$root\bin", "$root\usr\bin") | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append diff --git a/ci/tools/fetch_ctk_redistrib.py b/ci/tools/fetch_ctk_redistrib.py index 412dbc68a7c..1a75ed471a8 100644 --- a/ci/tools/fetch_ctk_redistrib.py +++ b/ci/tools/fetch_ctk_redistrib.py @@ -9,11 +9,9 @@ from __future__ import annotations import argparse +import hashlib import json import sys -import urllib.error -import urllib.parse -import urllib.request from pathlib import Path from typing import Any @@ -29,6 +27,22 @@ "cuda_cccl": ("cccl",), } +# Known-good sha256 of the redistrib metadata JSON, keyed by CUDA version. +# Per-archive verification is only trustworthy if the JSON that carries the +# archive hashes is itself trusted, so we pin the metadata too. A version that +# is absent here is not blocked -- failing closed would break CI on every new +# CTK -- but `verify-metadata` emits a warning; add the version's hash below as +# it enters the matrix to pin it. Regenerate with: +# curl -fsSL \ +# https://developer.download.nvidia.com/compute/cuda/redist/redistrib_.json \ +# | sha256sum +KNOWN_REDISTRIB_SHA256: dict[str, str] = { + "12.6.3": "9c598598457a6463eb92889080c16b2b9dc04150e501b8bfc1536d403ba70aaf", + "12.9.1": "8335301010b0023ee1ff61eb11e2600ca62002d76780de4089011ad77e0c7630", + "13.0.2": "fce66717a81c510ffeb89ecc3e79849ab34af3b80139f750876d9033e31d71c2", + "13.4.2": "015b97aa3977d4d0bade1631b226b6d4cbe48751cc841aad9193295df7f9047a", +} + def host_platform_to_subdir(host_platform: str) -> str: try: @@ -59,24 +73,8 @@ def filter_static_components(components: list[str], host_platform: str, cuda_ver return filtered -def validate_metadata_url(metadata_url: str) -> str: - parsed = urllib.parse.urlsplit(metadata_url) - if parsed.scheme != "https" or not parsed.netloc: - raise ValueError(f"metadata URL must be an https URL: {metadata_url!r}") - return metadata_url - - -def load_metadata(*, metadata_path: str | None, metadata_url: str | None) -> dict[str, Any]: - if (metadata_path is None) == (metadata_url is None): - raise ValueError("exactly one of --metadata-path or --metadata-url is required") - - if metadata_path is not None: - return json.loads(Path(metadata_path).read_text(encoding="utf-8")) - - assert metadata_url is not None - metadata_url = validate_metadata_url(metadata_url) - with urllib.request.urlopen(metadata_url) as response: # noqa: S310 - scheme is restricted to https above - return json.load(response) +def load_metadata(metadata_path: str) -> dict[str, Any]: + return json.loads(Path(metadata_path).read_text(encoding="utf-8")) def resolve_component_name(metadata: dict[str, Any], component: str) -> str: @@ -109,7 +107,9 @@ def filter_components( return filtered, skipped -def get_component_relative_path(metadata: dict[str, Any], *, host_platform: str, component: str) -> str: +def get_component_subdir_info( + metadata: dict[str, Any], *, host_platform: str, component: str +) -> tuple[str, dict[str, Any]]: ctk_subdir = host_platform_to_subdir(host_platform) component = resolve_component_name(metadata, component) component_info = metadata.get(component) @@ -119,13 +119,70 @@ def get_component_relative_path(metadata: dict[str, Any], *, host_platform: str, subdir_info = component_info.get(ctk_subdir) if subdir_info is None: raise KeyError(f"CTK component {component!r} is not available for redistrib subdir {ctk_subdir!r}") + return ctk_subdir, subdir_info + +def get_component_relative_path(metadata: dict[str, Any], *, host_platform: str, component: str) -> str: + ctk_subdir, subdir_info = get_component_subdir_info(metadata, host_platform=host_platform, component=component) relative_path = subdir_info.get("relative_path") if relative_path is None: raise KeyError(f"CTK component {component!r} for redistrib subdir {ctk_subdir!r} is missing 'relative_path'") return relative_path +def get_component_sha256(metadata: dict[str, Any], *, host_platform: str, component: str) -> str: + ctk_subdir, subdir_info = get_component_subdir_info(metadata, host_platform=host_platform, component=component) + sha256 = subdir_info.get("sha256") + if not sha256: + raise KeyError(f"CTK component {component!r} for redistrib subdir {ctk_subdir!r} is missing 'sha256'") + return sha256 + + +def compute_file_sha256(path: str) -> str: + digest = hashlib.sha256() + with open(path, "rb") as handle: + for chunk in iter(lambda: handle.read(1 << 20), b""): + digest.update(chunk) + return digest.hexdigest() + + +def verify_metadata_sha256(*, cuda_version: str, metadata_path: str, metadata_source: str = "public") -> int: + """Verify the redistrib JSON against the pinned hash for its CUDA version. + + The pins describe the public developer.download.nvidia.com manifests. A + "custom" source (an alternate redistrib server) may publish a different + manifest under the same version number, so it is never checked against + them; it only gets a warning. + + Returns a process exit code: 1 on a mismatch (fail closed), 0 when the hash + matches, the version is not pinned yet, or the source is custom (warn only). + """ + actual = compute_file_sha256(metadata_path) + if metadata_source == "custom": + print( + f"WARNING: redistrib metadata for CUDA {cuda_version!r} comes from a custom source and is not pinned; " + f"got {actual}.", + file=sys.stderr, + ) + return 0 + expected = KNOWN_REDISTRIB_SHA256.get(cuda_version) + if expected is None: + print( + f"WARNING: no pinned redistrib sha256 for CUDA {cuda_version!r}; got {actual}. " + f"Add it to KNOWN_REDISTRIB_SHA256 in {Path(__file__).name} to pin it.", + file=sys.stderr, + ) + return 0 + if actual != expected: + print( + f"ERROR: redistrib metadata sha256 mismatch for CUDA {cuda_version!r}: expected {expected}, got {actual}", + file=sys.stderr, + ) + return 1 + print(f"OK: redistrib metadata sha256 verified for CUDA {cuda_version}") + return 0 + + def parse_args(argv: list[str] | None = None) -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) subparsers = parser.add_subparsers(dest="command", required=True) @@ -134,14 +191,22 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: filter_parser.add_argument("--host-platform", required=True) filter_parser.add_argument("--cuda-version", required=True) filter_parser.add_argument("--components", required=True) - filter_parser.add_argument("--metadata-path") - filter_parser.add_argument("--metadata-url") + filter_parser.add_argument("--metadata-path", required=True) relpath_parser = subparsers.add_parser("component-relative-path") relpath_parser.add_argument("--host-platform", required=True) relpath_parser.add_argument("--component", required=True) - relpath_parser.add_argument("--metadata-path") - relpath_parser.add_argument("--metadata-url") + relpath_parser.add_argument("--metadata-path", required=True) + + sha_parser = subparsers.add_parser("component-sha256") + sha_parser.add_argument("--host-platform", required=True) + sha_parser.add_argument("--component", required=True) + sha_parser.add_argument("--metadata-path", required=True) + + verify_parser = subparsers.add_parser("verify-metadata") + verify_parser.add_argument("--cuda-version", required=True) + verify_parser.add_argument("--metadata-path", required=True) + verify_parser.add_argument("--metadata-source", choices=("public", "custom"), default="public") return parser.parse_args(argv) @@ -150,7 +215,15 @@ def main(argv: list[str] | None = None) -> int: args = parse_args(argv) try: - metadata = load_metadata(metadata_path=args.metadata_path, metadata_url=args.metadata_url) + # verify-metadata hashes the file directly; it does not parse the JSON. + if args.command == "verify-metadata": + return verify_metadata_sha256( + cuda_version=args.cuda_version, + metadata_path=args.metadata_path, + metadata_source=args.metadata_source, + ) + + metadata = load_metadata(args.metadata_path) if args.command == "filter-components": filtered, skipped = filter_components( @@ -177,8 +250,18 @@ def main(argv: list[str] | None = None) -> int: ) return 0 + if args.command == "component-sha256": + print( + get_component_sha256( + metadata, + host_platform=args.host_platform, + component=args.component, + ) + ) + return 0 + raise AssertionError(f"unexpected command: {args.command!r}") - except (ValueError, KeyError, OSError, urllib.error.URLError, json.JSONDecodeError) as exc: + except (ValueError, KeyError, OSError, json.JSONDecodeError) as exc: print(f"ERROR: {exc}", file=sys.stderr) return 1 diff --git a/ci/tools/tests/test_fetch_ctk_redistrib.py b/ci/tools/tests/test_fetch_ctk_redistrib.py new file mode 100644 index 00000000000..dad75da1a1b --- /dev/null +++ b/ci/tools/tests/test_fetch_ctk_redistrib.py @@ -0,0 +1,167 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent)) +from fetch_ctk_redistrib import ( + compute_file_sha256, + get_component_relative_path, + get_component_sha256, + main, + verify_metadata_sha256, +) + +# Minimal redistrib-shaped metadata. "cccl" exercises the cuda_cccl -> cccl +# alias; "no_sha" lacks the sha256 field. +FAKE_METADATA = { + "cuda_nvcc": { + "linux-x86_64": { + "relative_path": "cuda_nvcc/linux-x86_64/cuda_nvcc-linux-x86_64-13.0.0-archive.tar.xz", + "sha256": "a" * 64, + }, + }, + "cccl": { + "linux-x86_64": { + "relative_path": "cccl/linux-x86_64/cccl-linux-x86_64-13.0.0-archive.tar.xz", + "sha256": "b" * 64, + }, + }, + "no_sha": { + "linux-x86_64": { + "relative_path": "no_sha/linux-x86_64/no_sha.tar.xz", + }, + }, +} + + +@pytest.mark.agent_authored(model="claude-opus-4.8") +def test_get_component_sha256_returns_hash(): + assert get_component_sha256(FAKE_METADATA, host_platform="linux-64", component="cuda_nvcc") == "a" * 64 + + +@pytest.mark.agent_authored(model="claude-opus-4.8") +def test_get_component_sha256_resolves_alias(): + # cuda_cccl is absent; it must resolve to the renamed cccl key. + assert get_component_sha256(FAKE_METADATA, host_platform="linux-64", component="cuda_cccl") == "b" * 64 + + +@pytest.mark.agent_authored(model="claude-opus-4.8") +def test_get_component_sha256_unknown_component_raises(): + with pytest.raises(KeyError): + get_component_sha256(FAKE_METADATA, host_platform="linux-64", component="does_not_exist") + + +@pytest.mark.agent_authored(model="claude-opus-4.8") +def test_get_component_sha256_unavailable_subdir_raises(): + with pytest.raises(KeyError): + get_component_sha256(FAKE_METADATA, host_platform="win-64", component="cuda_nvcc") + + +@pytest.mark.agent_authored(model="claude-opus-4.8") +def test_get_component_sha256_missing_field_raises(): + with pytest.raises(KeyError): + get_component_sha256(FAKE_METADATA, host_platform="linux-64", component="no_sha") + + +@pytest.mark.agent_authored(model="claude-opus-4.8") +def test_get_component_relative_path_still_resolves(): + assert get_component_relative_path(FAKE_METADATA, host_platform="linux-64", component="cuda_nvcc").endswith( + "cuda_nvcc-linux-x86_64-13.0.0-archive.tar.xz" + ) + + +@pytest.mark.agent_authored(model="claude-opus-4.8") +def test_verify_metadata_match(tmp_path, monkeypatch): + f = tmp_path / "redistrib.json" + f.write_bytes(b"pinned-metadata") + monkeypatch.setitem( + sys.modules["fetch_ctk_redistrib"].KNOWN_REDISTRIB_SHA256, + "1.2.3", + compute_file_sha256(str(f)), + ) + assert verify_metadata_sha256(cuda_version="1.2.3", metadata_path=str(f)) == 0 + + +@pytest.mark.agent_authored(model="claude-opus-4.8") +def test_verify_metadata_mismatch_fails_closed(tmp_path, monkeypatch): + f = tmp_path / "redistrib.json" + f.write_bytes(b"tampered-metadata") + monkeypatch.setitem( + sys.modules["fetch_ctk_redistrib"].KNOWN_REDISTRIB_SHA256, + "1.2.3", + "f" * 64, + ) + assert verify_metadata_sha256(cuda_version="1.2.3", metadata_path=str(f)) == 1 + + +@pytest.mark.agent_authored(model="claude-opus-4.8") +def test_verify_metadata_unknown_version_warns(tmp_path, capsys): + f = tmp_path / "redistrib.json" + f.write_bytes(b"whatever") + # An unpinned version must not fail closed (it would break CI on every new + # CTK), but it must emit a warning. + assert verify_metadata_sha256(cuda_version="99.9.9", metadata_path=str(f)) == 0 + assert "WARNING" in capsys.readouterr().err + + +@pytest.mark.agent_authored(model="claude-opus-4.8") +def test_main_component_sha256(tmp_path, capsys): + f = tmp_path / "redistrib.json" + f.write_text(json.dumps(FAKE_METADATA), encoding="utf-8") + rc = main( + [ + "component-sha256", + "--host-platform", + "linux-64", + "--component", + "cuda_nvcc", + "--metadata-path", + str(f), + ] + ) + assert rc == 0 + assert capsys.readouterr().out.strip() == "a" * 64 + + +@pytest.mark.agent_authored(model="claude-opus-4.8") +def test_main_verify_metadata_mismatch_returns_1(tmp_path, monkeypatch): + f = tmp_path / "redistrib.json" + f.write_bytes(b"tampered") + monkeypatch.setitem( + sys.modules["fetch_ctk_redistrib"].KNOWN_REDISTRIB_SHA256, + "1.2.3", + "f" * 64, + ) + rc = main(["verify-metadata", "--cuda-version", "1.2.3", "--metadata-path", str(f)]) + assert rc == 1 + + +@pytest.mark.agent_authored(model="claude-opus-5.5") +def test_main_verify_metadata_custom_source_ignores_public_pin(tmp_path, monkeypatch, capsys): + # An alternate-server manifest may differ from the public one for the same version; + # the public pin must not fail it. + f = tmp_path / "redistrib.json" + f.write_bytes(b"custom-server-metadata") + monkeypatch.setitem( + sys.modules["fetch_ctk_redistrib"].KNOWN_REDISTRIB_SHA256, + "1.2.3", + "f" * 64, + ) + rc = main(["verify-metadata", "--cuda-version", "1.2.3", "--metadata-path", str(f), "--metadata-source", "custom"]) + assert rc == 0 + assert "WARNING" in capsys.readouterr().err + + +@pytest.mark.agent_authored(model="claude-opus-5.5") +def test_verify_metadata_custom_source_unpinned_version_warns(tmp_path, capsys): + f = tmp_path / "redistrib.json" + f.write_bytes(b"custom-server-metadata") + assert verify_metadata_sha256(cuda_version="99.9.9", metadata_path=str(f), metadata_source="custom") == 0 + assert "WARNING" in capsys.readouterr().err From 8aa3c1854290d51c54033128a9dc9a91b8c4e479 Mon Sep 17 00:00:00 2001 From: Ralf Juengling Date: Tue, 3 Nov 2026 14:22:02 -0800 Subject: [PATCH 2/5] ci: hash the fetch_ctk manifest via stdin for the cache key On Windows, RUNNER_TEMP contains backslashes, and Git for Windows' sha256sum prefixes its output line with a backslash for such file names. The first 8 characters then included the backslash, producing an invalid cache key and breaking 'tar -czvf' on the cache filename. Read the manifest from stdin so no file name is printed. --- .github/actions/fetch_ctk/action.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/actions/fetch_ctk/action.yml b/.github/actions/fetch_ctk/action.yml index 3beca78fd66..3d3d19cc4bf 100644 --- a/.github/actions/fetch_ctk/action.yml +++ b/.github/actions/fetch_ctk/action.yml @@ -76,7 +76,9 @@ runs: # Alternate redist servers (non-default) publish different manifests for the # same CUDA version; keying on the manifest hash keeps them from restoring # each other's toolkit. - MANIFEST_SHA8="$(sha256sum "$CTK_JSON_FILE" | cut -c1-8)" + # Hash via stdin: with a backslash in the path (Windows RUNNER_TEMP), sha256sum + # prefixes its output line with "\", which would end up in the cache key. + MANIFEST_SHA8="$(sha256sum < "$CTK_JSON_FILE" | cut -c1-8)" HASH=$(echo -n "${CTK_CACHE_COMPONENTS}" | sha256sum | awk '{print $1}') { echo "CTK_CACHE_KEY=mini-ctk-${{ inputs.cuda-version }}-${{ inputs.host-platform }}-$HASH-m$MANIFEST_SHA8" From af01a00f8ba2ac42634e0e8513b1d564d5ef739d Mon Sep 17 00:00:00 2001 From: Ralf Juengling Date: Tue, 3 Nov 2026 14:25:31 -0800 Subject: [PATCH 3/5] ci: address CodeRabbit feedback on fetch_ctk and install_windows_deps - install_windows_deps: skip the WSL launcher (System32/SysWOW64/ WindowsApps bash) when choosing the shell for the dependency check, and fail the check if DEPENDENT_EXES did not reach Bash. A WSL bash does not receive the Windows environment, so its empty loop reported success and skipped installing portable Git. - fetch_ctk: strip a trailing slash from redist-base-url so the public server is still recognized (and its metadata pin enforced), and add --fail --retry 3 --retry-delay 2 to the manifest download so HTTP errors surface clearly instead of as a later hash or JSON error. --- .github/actions/fetch_ctk/action.yml | 4 +++- .github/actions/install_windows_deps/action.yml | 8 ++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/actions/fetch_ctk/action.yml b/.github/actions/fetch_ctk/action.yml index 3d3d19cc4bf..eb94f542cad 100644 --- a/.github/actions/fetch_ctk/action.yml +++ b/.github/actions/fetch_ctk/action.yml @@ -49,9 +49,11 @@ runs: # Use the runtime workspace mount so this also works inside container jobs. CTK_REDIST_TOOL="${GITHUB_WORKSPACE}/ci/tools/fetch_ctk_redistrib.py" CTK_BASE_URL="${{ inputs.redist-base-url }}" + # A trailing slash would defeat the public-server comparison below and double up in URLs. + CTK_BASE_URL="${CTK_BASE_URL%/}" # RUNNER_TEMP persists across steps, so "Get CUDA components" reuses this file. CTK_JSON_FILE="$RUNNER_TEMP/redistrib-${{ inputs.cuda-version }}.json" - curl -LSs "${CTK_BASE_URL}/redistrib_${{ inputs.cuda-version }}.json" -o "$CTK_JSON_FILE" + curl -LSs --fail --retry 3 --retry-delay 2 "${CTK_BASE_URL}/redistrib_${{ inputs.cuda-version }}.json" -o "$CTK_JSON_FILE" # Pin the metadata JSON: the per-archive sha256 checks below are only # trustworthy if the JSON that carries those hashes is itself trusted. diff --git a/.github/actions/install_windows_deps/action.yml b/.github/actions/install_windows_deps/action.yml index 87edabb9dfa..b6e9c8b2e1b 100644 --- a/.github/actions/install_windows_deps/action.yml +++ b/.github/actions/install_windows_deps/action.yml @@ -22,8 +22,12 @@ runs: if ($env:DEPENDENT_EXES -notmatch '^\s*[a-zA-Z0-9_.-]+(\s+[a-zA-Z0-9_.-]+)*\s*$') { throw 'Expected a space-separated list of executable names.' } - $check = 'missing=0; for tool in $DEPENDENT_EXES; do command -v "$tool" || missing=1; done; exit "$missing"' - $bash = Get-Command bash -ErrorAction SilentlyContinue + # Fail the check if the list did not reach Bash (a WSL bash would not receive it). + $check = 'missing=0; [ -n "$DEPENDENT_EXES" ] || missing=1; for tool in $DEPENDENT_EXES; do command -v "$tool" || missing=1; done; exit "$missing"' + # Skip the WSL launcher in System32/WindowsApps: it is not a Git for Windows bash. + $bash = Get-Command bash -All -ErrorAction SilentlyContinue | + Where-Object { $_.Source -notmatch '\\(System32|SysWOW64|WindowsApps)\\' } | + Select-Object -First 1 if ($bash) { & $bash.Source --noprofile --norc -c $check if ($LASTEXITCODE -eq 0) { From aa4689fa88cf81478329854ba707cf8bbcba54e9 Mon Sep 17 00:00:00 2001 From: Ralf Juengling Date: Tue, 3 Nov 2026 15:18:58 -0800 Subject: [PATCH 4/5] ci: key fetch_ctk cache on the full manifest hash; keep Git Bash first on PATH - fetch_ctk: use the full SHA-256 of the manifest in the cache key and filename instead of 8 hex digits, so an alternate server cannot share a key with the public server by colliding a truncated hash. - install_windows_deps: when the validated Git Bash is not the first bash on PATH (for example the WSL launcher comes first), prepend its directory to PATH and GITHUB_PATH so later 'shell: bash' steps use Git Bash. --- .github/actions/fetch_ctk/action.yml | 10 +++++----- .github/actions/install_windows_deps/action.yml | 9 +++++++++ 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/.github/actions/fetch_ctk/action.yml b/.github/actions/fetch_ctk/action.yml index eb94f542cad..ab41918185f 100644 --- a/.github/actions/fetch_ctk/action.yml +++ b/.github/actions/fetch_ctk/action.yml @@ -76,15 +76,15 @@ runs: --metadata-path "$CTK_JSON_FILE")" # Alternate redist servers (non-default) publish different manifests for the - # same CUDA version; keying on the manifest hash keeps them from restoring - # each other's toolkit. + # same CUDA version; keying on the full manifest hash keeps them from restoring + # each other's toolkit (a truncated hash could be collided on purpose). # Hash via stdin: with a backslash in the path (Windows RUNNER_TEMP), sha256sum # prefixes its output line with "\", which would end up in the cache key. - MANIFEST_SHA8="$(sha256sum < "$CTK_JSON_FILE" | cut -c1-8)" + MANIFEST_SHA256="$(sha256sum < "$CTK_JSON_FILE" | awk '{print $1}')" HASH=$(echo -n "${CTK_CACHE_COMPONENTS}" | sha256sum | awk '{print $1}') { - echo "CTK_CACHE_KEY=mini-ctk-${{ inputs.cuda-version }}-${{ inputs.host-platform }}-$HASH-m$MANIFEST_SHA8" - echo "CTK_CACHE_FILENAME=mini-ctk-${{ inputs.cuda-version }}-${{ inputs.host-platform }}-$HASH-m$MANIFEST_SHA8.tar.gz" + echo "CTK_CACHE_KEY=mini-ctk-${{ inputs.cuda-version }}-${{ inputs.host-platform }}-$HASH-m$MANIFEST_SHA256" + echo "CTK_CACHE_FILENAME=mini-ctk-${{ inputs.cuda-version }}-${{ inputs.host-platform }}-$HASH-m$MANIFEST_SHA256.tar.gz" echo "CTK_CACHE_COMPONENTS=${CTK_CACHE_COMPONENTS}" echo "CTK_BASE_URL=${CTK_BASE_URL}" echo "CTK_JSON_FILE=${CTK_JSON_FILE}" diff --git a/.github/actions/install_windows_deps/action.yml b/.github/actions/install_windows_deps/action.yml index b6e9c8b2e1b..88fe14f355b 100644 --- a/.github/actions/install_windows_deps/action.yml +++ b/.github/actions/install_windows_deps/action.yml @@ -31,6 +31,15 @@ runs: if ($bash) { & $bash.Source --noprofile --norc -c $check if ($LASTEXITCODE -eq 0) { + # Later `shell: bash` steps resolve `bash` through PATH. If that is not the + # Git Bash validated above (e.g. the WSL launcher comes first), put it first. + $pathBash = Get-Command bash -ErrorAction SilentlyContinue + if ($pathBash -and ($pathBash.Source -ne $bash.Source)) { + $bashDir = Split-Path -Parent $bash.Source + $env:PATH = "$bashDir;$env:PATH" + $bashDir | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + Write-Host "Prepended $bashDir to PATH so steps use Git Bash instead of $($pathBash.Source)." + } Write-Host 'All dependencies are found. Do nothing.' exit 0 } From 794c0f2a115b3174f4822c782cd0e289a37a365e Mon Sep 17 00:00:00 2001 From: Ralf Juengling Date: Tue, 3 Nov 2026 15:49:22 -0800 Subject: [PATCH 5/5] ci: keep double quotes out of the install_windows_deps bash check Windows PowerShell 5.1 strips embedded double quotes when passing arguments to native commands, so `[ -n "$DEPENDENT_EXES" ]` reached bash as `[ -n curl unzip ... ]` and failed with "too many arguments", even with every tool present. Count the words with `set --` instead. --- .github/actions/install_windows_deps/action.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/actions/install_windows_deps/action.yml b/.github/actions/install_windows_deps/action.yml index 88fe14f355b..2a7b9166f2e 100644 --- a/.github/actions/install_windows_deps/action.yml +++ b/.github/actions/install_windows_deps/action.yml @@ -23,7 +23,9 @@ runs: throw 'Expected a space-separated list of executable names.' } # Fail the check if the list did not reach Bash (a WSL bash would not receive it). - $check = 'missing=0; [ -n "$DEPENDENT_EXES" ] || missing=1; for tool in $DEPENDENT_EXES; do command -v "$tool" || missing=1; done; exit "$missing"' + # Keep double quotes out of $check: Windows PowerShell 5.1 strips them when + # passing arguments to native commands. The names are validated above. + $check = 'missing=0; set -- $DEPENDENT_EXES; [ $# -gt 0 ] || missing=1; for tool in $DEPENDENT_EXES; do command -v $tool || missing=1; done; exit $missing' # Skip the WSL launcher in System32/WindowsApps: it is not a Git for Windows bash. $bash = Get-Command bash -All -ErrorAction SilentlyContinue | Where-Object { $_.Source -notmatch '\\(System32|SysWOW64|WindowsApps)\\' } |