diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index a8b8bec..0f3501f 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -11,6 +11,10 @@ on: Example: [{"ubuntu":"22.04","cuda":"12.8.1","archs":"75;80;86;89"}] type: string required: true + release_tag: + description: "Optional v tag for a public wheel release" + type: string + default: "" artifact_retention_days: description: "Number of days to retain uploaded artifacts" type: number @@ -40,6 +44,8 @@ jobs: config: ${{ fromJson(inputs.matrix_json) }} env: CONFIG_NAME: ${{ matrix.config.platform || 'x86_64' }}-cuda${{ matrix.config.cuda }}-ubuntu${{ matrix.config.ubuntu }} + RELEASE_TAG: ${{ inputs.release_tag }} + RELEASE_COMMIT: ${{ github.sha }} CUDA_VERSION: ${{ matrix.config.cuda }} UBUNTU_VERSION: ${{ matrix.config.ubuntu }} BASE_IMAGE: ${{ matrix.config.base_image }} @@ -107,10 +113,10 @@ jobs: if-no-files-found: warn - name: Upload Python wheel - if: ${{ always() && inputs.upload_binaries }} + if: inputs.upload_binaries uses: actions/upload-artifact@v6 with: name: pycunls-${{ env.CONFIG_NAME }} path: output/wheel/*.whl retention-days: ${{ inputs.artifact_retention_days }} - if-no-files-found: warn + if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..77263fb --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,120 @@ +name: Release Python wheels + +on: + push: + tags: ['v*'] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: release-${{ github.repository }}-${{ github.ref_name }} + cancel-in-progress: false + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + PYPI_PUBLISH_REPOSITORY: nvidia-isaac/cuNLS + PYPI_ASSET_SOURCE: github-release + WHEEL_DIR: pypi-wheels + KITMAKER_PROJECTS: | + pycunls-cu12=6865 + pycunls-cu13=6866 + KITMAKER_PIC: hguillen@nvidia.com + PYPI_PLATFORM: manylinux_*_x86_64 + PYPI_PROVENANCE_PATH: pycunls/_build_provenance.json + PYPI_PER_RELEASE_PLC: ${{ vars.PYPI_PER_RELEASE_PLC || 'yes' }} + PYPI_PUBLISH_CELLS: &release_cells >- + [{"cuda":"12.8.1","ubuntu":"22.04","python":"3.10","arch":"x86_64","archs":"75;80;86;89","artifact_dir":"pycunls-x86_64-cuda12.8.1-ubuntu22.04"}, + {"cuda":"13.2.0","ubuntu":"22.04","python":"3.10","arch":"x86_64","archs":"75;80;86;89;100","artifact_dir":"pycunls-x86_64-cuda13.2.0-ubuntu22.04"}, + {"cuda":"12.8.1","ubuntu":"24.04","python":"3.12","arch":"x86_64","archs":"75;80;86;89","artifact_dir":"pycunls-x86_64-cuda12.8.1-ubuntu24.04"}, + {"cuda":"13.2.0","ubuntu":"24.04","python":"3.12","arch":"x86_64","archs":"75;80;86;89;100","artifact_dir":"pycunls-x86_64-cuda13.2.0-ubuntu24.04"}] + RELEASE_TAG: ${{ github.ref_name }} + RELEASE_COMMIT: ${{ github.sha }} + +jobs: + build-matrix: + if: github.repository == 'nvidia-isaac/cuNLS' && startsWith(github.ref, 'refs/tags/v') + uses: ./.github/workflows/build-and-test.yml + with: + matrix_json: *release_cells + release_tag: ${{ github.ref_name }} + artifact_retention_days: 30 + upload_binaries: true + timeout_minutes: 120 + + release: + if: github.repository == 'nvidia-isaac/cuNLS' && startsWith(github.ref, 'refs/tags/v') + needs: build-matrix + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: write + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + - uses: actions/download-artifact@v7 + with: + pattern: pycunls-x86_64-* + path: pypi-wheels + - run: bash scripts/release_pycunls.sh + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: nvidia-isaac/cuNLS + + pypi-prepare: + if: github.repository == 'nvidia-isaac/cuNLS' && startsWith(github.ref, 'refs/tags/v') + needs: build-matrix + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + - uses: actions/download-artifact@v7 + with: + pattern: pycunls-x86_64-* + path: pypi-wheels + - run: sh scripts/publish_pypi.sh prepare + - uses: actions/upload-artifact@v6 + with: + name: pypi-manifest + path: pypi-manifest.json + if-no-files-found: error + retention-days: 30 + + pypi-publish: + if: >- + github.repository == 'nvidia-isaac/cuNLS' && + startsWith(github.ref, 'refs/tags/v') && + vars.PYPI_PUBLISH_ENABLED == 'true' + needs: [release, pypi-prepare] + runs-on: ${{ fromJson(vars.PYPI_PUBLISH_RUNNER || '["self-hosted","gpu"]') }} + timeout-minutes: 180 + environment: pypi + concurrency: + group: pypi-${{ github.repository }}-${{ github.ref_name }} + cancel-in-progress: false + env: + KITMAKER_TOKEN: ${{ secrets.KITMAKER_TOKEN }} + PYPI_DRY_RUN_ONLY: ${{ vars.PYPI_DRY_RUN_ONLY || '1' }} + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + - uses: actions/download-artifact@v7 + with: + name: pypi-manifest + path: . + - run: sh scripts/publish_pypi.sh publish + - uses: actions/upload-artifact@v6 + if: always() + with: + name: pypi-publication-record-${{ github.run_id }}-${{ github.run_attempt }} + path: | + pypi-manifest.json + pypi-release-uuids.txt + pypi-status-*.json + if-no-files-found: warn + retention-days: 30 diff --git a/.gitignore b/.gitignore index 32214a7..1e905b8 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,11 @@ viz/ # Design notes are kept locally, not in the repository docs/design/ + +# Generated Python release evidence +python/pycunls/_build_provenance.json +pypi-manifest.json +pypi-release-uuids.txt +pypi-status-*.json +pypi-wheels/ +pypi-publication-record*/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0d5ccd9..206bbfd 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,9 +5,23 @@ repos: - id: trailing-whitespace - id: end-of-file-fixer - id: check-yaml + - id: check-shebang-scripts-are-executable - repo: https://github.com/pre-commit/mirrors-clang-format rev: v15.0.7 hooks: - id: clang-format types_or: [c++, cuda] + + - repo: local + hooks: + - id: actionlint + name: Validate GitHub Actions workflows + entry: actionlint -shellcheck= + language: system + files: ^\.github/workflows/.*\.ya?ml$ + - id: shellcheck-publishing + name: Validate Python release scripts + entry: shellcheck + language: system + files: ^scripts/((build|test)_pycunls(_in_docker)?|publish_pypi|release_pycunls)\.sh$ diff --git a/README.md b/README.md index 142e3fd..715fc72 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,13 @@ a container with the source mounted read-only. Intermediate build directories live inside the container and are discarded; only the final `.whl` file is written to the host output directory. +Docker wheels use the distribution name `pycunls-cu12` or `pycunls-cu13` and the +matching CuPy dependency; both use `import pycunls`. With `RELEASE_TAG=v`, +the Docker build uses the committed checkout, excluding local changes and generated +files. The tag version must match `python/pyproject.toml` and +`python/pycunls/__init__.py`. Wheels include the source commit and measured CUDA +toolkit version and are repaired with auditwheel while keeping CUDA libraries external. + #### Build the wheel locally ```bash @@ -130,7 +137,7 @@ pip wheel . --no-build-isolation --no-deps --wheel-dir ../dist #### Install the wheel ```bash -pip install ./dist/pycunls-*.whl +pip install ./dist/pycunls*.whl ``` #### Editable install for development diff --git a/python/CMakeLists.txt b/python/CMakeLists.txt index 19bbc8c..14d6da2 100644 --- a/python/CMakeLists.txt +++ b/python/CMakeLists.txt @@ -43,4 +43,5 @@ install(DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/pycunls/ DESTINATION pycunls FILES_MATCHING PATTERN "*.py" PATTERN "*.pyi" - PATTERN "py.typed") + PATTERN "py.typed" + PATTERN "_build_provenance.json") diff --git a/scripts/Dockerfile b/scripts/Dockerfile index a8ef571..7a0cf88 100644 --- a/scripts/Dockerfile +++ b/scripts/Dockerfile @@ -29,8 +29,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ # Python venv with build tools (for pycunls wheel builds) RUN python3 -m venv /opt/venv ENV PATH="/opt/venv/bin:$PATH" +COPY scripts/requirements-build.txt /tmp/cunls-build-requirements.txt RUN pip install --upgrade pip && \ - pip install scikit-build-core nanobind build wheel pre-commit + pip install -r /tmp/cunls-build-requirements.txt # Sphinx docs dependencies COPY docs/sphinx/requirements.txt /tmp/cunls-docs-requirements.txt diff --git a/scripts/build_pycunls.sh b/scripts/build_pycunls.sh index 9ebba48..58c22c4 100755 --- a/scripts/build_pycunls.sh +++ b/scripts/build_pycunls.sh @@ -1,28 +1,114 @@ #!/bin/bash -# Runs inside the Docker container to build the pycunls wheel. -# Expected mounts: -# /cunls (read-only) — source tree -# OUTPUT_DIR env or /output (read-write) — wheel output directory +# Runs inside the existing CUDA Docker image; the checkout is read-only. set -euo pipefail -WHEEL_DIR=/tmp/pycunls_wheel -mkdir -p "$WHEEL_DIR" +: "${RELEASE_COMMIT:?Pass the original checkout SHA through build_pycunls_in_docker.sh}" +ACTUAL_CUDA_VERSION=$(python3 - <<'PY' +import json +import os +import re +import subprocess +from pathlib import Path -cp -r /cunls /tmp/cunls_src -rm -rf /tmp/cunls_src/python/build /tmp/cunls_src/build_python +cuda = Path('/usr/local/cuda') +match = re.search(r'release (\d+\.\d+)', subprocess.check_output([str(cuda / 'bin/nvcc'), '--version'], text=True)) +if not match: + raise SystemExit('Cannot identify the installed CUDA toolkit; use a CUDA development image') +path = cuda / 'version.json' +actual = json.loads(path.read_text())['cuda']['version'] if path.exists() else match[1] +if actual.split('.')[:2] != match[1].split('.'): + raise SystemExit(f'CUDA version.json {actual} disagrees with nvcc {match[1]}') +requested = os.environ.get('CUDA_VERSION') +# nvcc exposes major.minor when version.json has no toolkit patch version. +if requested and not (actual == requested or actual.startswith(requested + '.') or requested.startswith(actual + '.')): + raise SystemExit(f'Requested CUDA {requested}, but the build image contains {actual}; correct CUDA_VERSION or BASE_IMAGE') +print(actual) +PY +) +export CUDA_VERSION=$ACTUAL_CUDA_VERSION +export RELEASE_COMMIT +export RELEASE_TAG=${RELEASE_TAG:-} + +WORK_DIR=$(mktemp -d) +trap 'rm -rf "$WORK_DIR"' EXIT +mkdir -p "$WORK_DIR/raw" "$WORK_DIR/repaired" +cp -r /cunls "$WORK_DIR/source" +rm -rf "$WORK_DIR/source/python/build" "$WORK_DIR/source/build_python" +cd "$WORK_DIR/source/python" + +python3 - <<'PY' +import json +import os +import re +from pathlib import Path +from packaging.version import Version + +cuda = os.environ['CUDA_VERSION'] +major = cuda.split('.')[0] +if major not in ('12', '13'): + raise SystemExit(f'Unsupported CUDA version: {cuda}; expected CUDA 12 or 13') +sha = os.environ['RELEASE_COMMIT'] +if not re.fullmatch(r'[0-9a-f]{40}', sha): + raise SystemExit('RELEASE_COMMIT must be the full original checkout SHA') +path = Path('pyproject.toml') +text = path.read_text() +text, count = re.subn(r'^name = "pycunls"$', f'name = "pycunls-cu{major}"', text, flags=re.M) +if count != 1: + raise SystemExit('Expected one pycunls project name in pyproject.toml') +text = text.replace('cupy-cuda12x', f'cupy-cuda{major}x') +tag = os.environ['RELEASE_TAG'] +if tag: + if not tag.startswith('v'): + raise SystemExit('Release tag must start with v') + version = Version(tag[1:]) + if version.local or str(version) != tag[1:]: + raise SystemExit('Release tag must contain a normalized public PEP 440 version') + current = re.findall(r'^version = "([^"]+)"$', text, flags=re.M) + if current != [str(version)]: + raise SystemExit(f'Package version {current} differs from {tag}; update version sources before tagging') + init_version = re.findall(r'^__version__ = "([^"]+)"$', Path('pycunls/__init__.py').read_text(), flags=re.M) + if init_version != [str(version)]: + raise SystemExit(f'Import version {init_version} differs from {tag}; update version sources before tagging') +path.write_text(text) +Path('pycunls/_build_provenance.json').write_text(json.dumps({ + 'git_sha': sha, 'cuda_version': cuda, +}, sort_keys=True) + '\n') +PY -cd /tmp/cunls_src/python -# Without Ninja, scikit-build-core drives Makefiles serially unless a parallel -# level is set: build with every core. export CMAKE_BUILD_PARALLEL_LEVEL=${CMAKE_BUILD_PARALLEL_LEVEL:-$(nproc)} -pip wheel . --no-build-isolation --no-deps --wheel-dir "$WHEEL_DIR" +pip wheel . --no-build-isolation --no-deps --wheel-dir "$WORK_DIR/raw" + +# The system CUDA toolkit supplies these linked runtime libraries. Match SONAME +# families rather than assuming CUDA 12 and 13 use the same SONAME versions. +PLATFORM=$(python3 -c 'import os, platform; print("manylinux_" + os.confstr("CS_GNU_LIBC_VERSION").split()[1].replace(".", "_") + "_" + platform.machine())') +for wheel in "$WORK_DIR/raw"/*.whl; do + auditwheel repair "$wheel" --plat "$PLATFORM" --wheel-dir "$WORK_DIR/repaired" \ + --exclude 'libcudart.so.*' --exclude 'libcusparse.so.*' \ + --exclude 'libcublas.so.*' --exclude 'libcublasLt.so.*' \ + --exclude 'libcusolver.so.*' --exclude 'libcuda.so.*' +done + +python3 - "$WORK_DIR/repaired" <<'PY' +import json +import os +import re +import sys +import zipfile +from pathlib import Path -echo "" -echo "=========================================" -echo " Wheel built successfully" -echo "=========================================" -ls -lh "$WHEEL_DIR"/*.whl +wheels = list(Path(sys.argv[1]).glob('*.whl')) +if len(wheels) != 1: + raise SystemExit(f'Expected one repaired wheel, found {len(wheels)}') +with zipfile.ZipFile(wheels[0]) as archive: + provenance = json.loads(archive.read('pycunls/_build_provenance.json')) + if provenance != {'git_sha': os.environ['RELEASE_COMMIT'], 'cuda_version': os.environ['CUDA_VERSION']}: + raise SystemExit('Repaired wheel lost the original build provenance') + for member in archive.namelist(): + if re.search(r'(^|/)lib(cudart|cusparse|cublas|cusolver|cudss|cuda)[^/]*\.so', member): + raise SystemExit(f'CUDA runtime library must remain external: {member}') +PY OUTPUT=${OUTPUT_DIR:-/output} -cp "$WHEEL_DIR"/*.whl "$OUTPUT"/ -echo "Wheel copied to $OUTPUT/" +mkdir -p "$OUTPUT" +cp "$WORK_DIR/repaired"/*.whl "$OUTPUT/" +ls -lh "$OUTPUT"/*.whl diff --git a/scripts/build_pycunls_in_docker.sh b/scripts/build_pycunls_in_docker.sh index 8e7773d..0763228 100755 --- a/scripts/build_pycunls_in_docker.sh +++ b/scripts/build_pycunls_in_docker.sh @@ -11,14 +11,29 @@ LOCAL_OUTPUT_DIR=$(realpath -m "$LOCAL_OUTPUT_DIR") mkdir -p "$LOCAL_OUTPUT_DIR" -DOCKERFILE=$(dirname "$(realpath "$0")")/Dockerfile -DOCKER_BUILD_ARGS="" -[ -n "${CUDA_VERSION:-}" ] && DOCKER_BUILD_ARGS="$DOCKER_BUILD_ARGS --build-arg CUDA_VERSION=$CUDA_VERSION" -[ -n "${UBUNTU_VERSION:-}" ] && DOCKER_BUILD_ARGS="$DOCKER_BUILD_ARGS --build-arg UBUNTU_VERSION=$UBUNTU_VERSION" -[ -n "${BASE_IMAGE:-}" ] && DOCKER_BUILD_ARGS="$DOCKER_BUILD_ARGS --build-arg BASE_IMAGE=$BASE_IMAGE" +CHECKOUT_COMMIT=$(git rev-parse HEAD) +RELEASE_COMMIT=${RELEASE_COMMIT:-$CHECKOUT_COMMIT} +if [ "$RELEASE_COMMIT" != "$CHECKOUT_COMMIT" ]; then + echo "RELEASE_COMMIT differs from the checked-out source; build the release checkout." >&2 + exit 1 +fi +SOURCE_DIR=$(pwd) +if [ -n "${RELEASE_TAG:-}" ]; then + # A local release has no checkout action to remove ignored package inputs. + # Build the committed tree without deleting or packaging local edits. + SOURCE_DIR=$(mktemp -d) + trap 'rm -rf "$SOURCE_DIR"' EXIT + git archive "$CHECKOUT_COMMIT" | tar -x -C "$SOURCE_DIR" +fi + +DOCKERFILE="$SOURCE_DIR/scripts/Dockerfile" +DOCKER_BUILD_ARGS=() +[ -n "${CUDA_VERSION:-}" ] && DOCKER_BUILD_ARGS+=(--build-arg "CUDA_VERSION=$CUDA_VERSION") +[ -n "${UBUNTU_VERSION:-}" ] && DOCKER_BUILD_ARGS+=(--build-arg "UBUNTU_VERSION=$UBUNTU_VERSION") +[ -n "${BASE_IMAGE:-}" ] && DOCKER_BUILD_ARGS+=(--build-arg "BASE_IMAGE=$BASE_IMAGE") [ -n "${UBUNTU_PORTS_MIRROR:-}" ] \ - && DOCKER_BUILD_ARGS="$DOCKER_BUILD_ARGS --build-arg UBUNTU_PORTS_MIRROR=$UBUNTU_PORTS_MIRROR" -docker build -f "$DOCKERFILE" . --network host $DOCKER_BUILD_ARGS --tag cunls:local + && DOCKER_BUILD_ARGS+=(--build-arg "UBUNTU_PORTS_MIRROR=$UBUNTU_PORTS_MIRROR") +docker build -f "$DOCKERFILE" "$SOURCE_DIR" --network host "${DOCKER_BUILD_ARGS[@]}" --tag cunls:local TTY_FLAG="" [ -t 0 ] && TTY_FLAG="-it" @@ -27,9 +42,13 @@ TTY_FLAG="" # /cunls (ro) — source tree # /cunls_install (rw) — wheel output (host: $LOCAL_OUTPUT_DIR) docker run --runtime=nvidia --gpus all --rm $TTY_FLAG \ - -v "$(pwd):/cunls:ro" \ + -v "$SOURCE_DIR:/cunls:ro" \ -v "$LOCAL_OUTPUT_DIR:/cunls_install" \ -e OUTPUT_DIR=/cunls_install \ + -e RELEASE_COMMIT="$RELEASE_COMMIT" \ + -e RELEASE_TAG="${RELEASE_TAG:-}" \ + -e CUDA_VERSION="${CUDA_VERSION:-}" \ + -e CMAKE_BUILD_PARALLEL_LEVEL="${CMAKE_BUILD_PARALLEL_LEVEL:-}" \ cunls:local bash /cunls/scripts/build_pycunls.sh echo "" diff --git a/scripts/publish_pypi.sh b/scripts/publish_pypi.sh new file mode 100755 index 0000000..d27aead --- /dev/null +++ b/scripts/publish_pypi.sh @@ -0,0 +1,256 @@ +#!/bin/sh +# TEMPLATE_VERSION=1.9.0 +set -eu +set -f + +fail() { + printf '%s: actual=%s; expected=%s; fix=%s' "$1" "$2" "$3" "$4" | tr '\n' ' ' >&2 + printf '\n' >&2 + exit 1 +} +for dependency in curl jq unzip sha256sum; do + command -v "$dependency" >/dev/null || fail "$0" "$dependency missing" 'curl jq unzip sha256sum' "install $dependency on the runner host" +done +mode=${1:-} +case "$mode" in prepare|publish) ;; *) fail "$0" "$mode" 'prepare | publish' 'select one command' ;; esac +PYPI_ASSET_SOURCE=${PYPI_ASSET_SOURCE:-github-release} +KITMAKER_PIC=${KITMAKER_PIC:-} +RELEASE_TAG=${RELEASE_TAG:-${GITHUB_REF_NAME:-}} +RELEASE_COMMIT=${RELEASE_COMMIT:-${GITHUB_SHA:-}} +PYPI_PLATFORM=${PYPI_PLATFORM:-manylinux_*_x86_64} +PYPI_DRY_RUN_ONLY=${PYPI_DRY_RUN_ONLY:-1} +PYPI_PER_RELEASE_PLC=${PYPI_PER_RELEASE_PLC:-no} +PYPI_POLL_SECONDS=${PYPI_POLL_SECONDS:-10} +PYPI_TIMEOUT_SECONDS=${PYPI_TIMEOUT_SECONDS:-1800} +PYPI_INDEX_TIMEOUT_SECONDS=${PYPI_INDEX_TIMEOUT_SECONDS:-600} +KITMAKER_API=https://kitmaker-portal.nvidia.com/api/v0 +manifest=pypi-manifest.json +umask 077 +scratch=$(mktemp -d) +trap 'rm -rf "$scratch"' EXIT HUP INT TERM +normalize() { printf '%s' "$1" | tr '[:upper:]' '[:lower:]' | sed 's/[-_.][-_\.]*/-/g'; } +required() { + missing= + for input_name in "$@"; do + eval 'input_value=${'"$input_name"':-}' + [ -n "$input_value" ] || missing="$missing $input_name" + done + [ -z "$missing" ] || fail "$0" "missing:$missing" 'all required inputs set' 'export the listed names from the confirmed context' +} +repository_guard() { + [ "$PYPI_ASSET_SOURCE" = github-release ] || fail "$0" "$PYPI_ASSET_SOURCE" 'github-release' 'only public GitHub release assets are supported' + actual_repository=${GITHUB_REPOSITORY:-} + [ "$actual_repository" = "$PYPI_PUBLISH_REPOSITORY" ] || fail "$0" "$actual_repository" "$PYPI_PUBLISH_REPOSITORY" "private copies don't publish; run in the canonical repository" +} +public_version() { + printf '%s' "$1" | jq -eRs 'test("^([0-9]+!)?[0-9]+(\\.[0-9]+)*((a|b|rc)[0-9]+)?(\\.post[0-9]+)?(\\.dev[0-9]+)?$")' >/dev/null +} +project_map() { + printf '%s\n' "${KITMAKER_PROJECTS:-}" | jq -Rsc ' + split("\n") | map(select(length > 0) | split("=")) | + if length == 0 or any(.[]; length != 2 or (.[0] | test("^[a-z0-9]+(-[a-z0-9]+)*$") | not) or (.[1] | test("^[1-9][0-9]*$") | not)) + then error("expected canonical-name=numeric-id, one per line") + elif (map(.[0]) | unique | length) != length or (map(.[1]) | unique | length) != length + then error("duplicate name or project ID") + else map({name:.[0], id:.[1]}) end' > "$scratch/map.json" +} +valid_cell_directories() { + printf '%s' "$1" | jq -e 'type == "array" and length > 0 and all(.[]; + (.cuda | type == "string") and (.artifact_dir | type == "string" and (. == "." or (test("^[A-Za-z0-9_][A-Za-z0-9_./-]*$") and (split("/") | all(.[]; . != ".." and . != "." and . != "")))))) and + (length == 1 or all(.[]; .artifact_dir != ".")) and + (map(.artifact_dir) | unique | length) == length' >/dev/null +} +# Preserve error bodies and never retry a POST automatically. +http() { + http_status=000 + http_rc=0 + : > "$scratch/response" + http_status=$(curl --fail-with-body --show-error --location --proto '=https' --proto-redir '=https' --connect-timeout 15 --max-time 120 --output "$scratch/response" --write-out '%{http_code}' "$@") || http_rc=$? + printf 'HTTP %s (curl=%s)\n' "$http_status" "$http_rc" +} +kitmaker() { + http --header "Authorization: Bearer $KITMAKER_TOKEN" --header 'Content-Type: application/json' "$@" + cat "$scratch/response" + printf '\n' + [ "$http_rc" -eq 0 ] || fail "$KITMAKER_API" "HTTP $http_status, curl $http_rc" 'successful response' 'inspect the response; check token, network and NVIDIA CA; never blindly resubmit' +} + +prepare() { + required PYPI_PUBLISH_REPOSITORY PYPI_ASSET_SOURCE RELEASE_TAG RELEASE_COMMIT WHEEL_DIR KITMAKER_PROJECTS KITMAKER_PIC PYPI_PROVENANCE_PATH PYPI_PUBLISH_CELLS + repository_guard + case "$PYPI_PROVENANCE_PATH" in /*|../*|*/../*|*..|*\**|*\?*|*\[*) fail "$0" "$PYPI_PROVENANCE_PATH" 'literal wheel-relative provenance path' 'use import_package/_build_provenance.json' ;; esac + case "$RELEASE_COMMIT" in *[!a-fA-F0-9]*|'') fail "$0" "$RELEASE_COMMIT" 'full git commit SHA' 'use the commit built by the release matrix' ;; esac + [ "${#RELEASE_COMMIT}" -eq 40 ] || fail "$0" "$RELEASE_COMMIT" '40-character git SHA' 'resolve the full release commit' + project_map || fail "$0" "$KITMAKER_PROJECTS" 'unique canonical-name=numeric-id pairs' 'correct KITMAKER_PROJECTS' + valid_cell_directories "$PYPI_PUBLISH_CELLS" || fail "$0" "$PYPI_PUBLISH_CELLS" 'selected cells with unique safe artifact_dir paths' 'record each selected cell artifact directory from the producer' + printf '%s\n' "$PYPI_PUBLISH_CELLS" > "$scratch/cells" + version=${RELEASE_TAG#v} + public_version "$version" || fail "$0" "$version" 'normalized public PEP 440 version, no +local' 'set v and rebuild all wheels' + [ -d "$WHEEL_DIR" ] || fail "$WHEEL_DIR" 'missing directory' 'downloaded build artifacts' 'download the selected wheel artifacts' + find "$WHEEL_DIR" -type f -name '*.whl' | LC_ALL=C sort > "$scratch/files" + : > "$scratch/entries" + while IFS= read -r wheel; do + filename=${wheel##*/} + stem=${filename%.whl} + platform=${stem##*-} + matched=0 + old_ifs=$IFS; IFS=. + for wheel_platform in $platform; do + # PYPI_PLATFORM is intentionally a caller-selected shell pattern. + # shellcheck disable=SC2254 + case "$wheel_platform" in $PYPI_PLATFORM) matched=1 ;; esac + done + IFS=$old_ifs + if [ "$matched" -eq 0 ]; then printf 'SKIP %s: platform=%s; selected=%s\n' "$wheel" "$platform" "$PYPI_PLATFORM"; continue; fi + fields=$(printf '%s' "$stem" | awk -F- '{print NF}') + [ "$fields" -eq 5 ] || fail "$wheel" "$filename ($fields fields)" 'five wheel filename fields; no build tag' 'remove the build tag in the producer and rebuild' + distribution=$(normalize "${stem%%-*}") + remainder=${stem#*-}; wheel_version=${remainder%%-*} + project_id=$(jq -r --arg name "$distribution" '.[] | select(.name == $name) | .id' "$scratch/map.json") + [ -n "$project_id" ] || fail "$wheel" "$distribution" "$KITMAKER_PROJECTS" 'register the distribution and add its Kitmaker project mapping' + [ "$wheel_version" = "$version" ] || fail "$wheel" "$wheel_version" "$version (no +local)" 'set the build version from the release tag and rebuild' + unzip -Z1 "$wheel" > "$scratch/members" || fail "$wheel" 'invalid ZIP' 'readable wheel archive' 'rebuild the wheel' + metadata_count=$(awk '/^[^\/]+\.dist-info\/METADATA$/ {n++} END {print n+0}' "$scratch/members") + [ "$metadata_count" -eq 1 ] || fail "$wheel" "$metadata_count METADATA entries" 'exactly one .dist-info/METADATA' 'rebuild the wheel with its packaging backend' + metadata_path=$(awk '/^[^\/]+\.dist-info\/METADATA$/ {print}' "$scratch/members") + unzip -p "$wheel" "$metadata_path" > "$scratch/metadata" || fail "$wheel" 'unreadable METADATA' 'readable core metadata' 'rebuild the wheel' + metadata_name=$(sed -n 's/^Name: //p' "$scratch/metadata" | tr -d '\r') + metadata_version=$(sed -n 's/^Version: //p' "$scratch/metadata" | tr -d '\r') + [ "$(normalize "$metadata_name")" = "$distribution" ] || fail "$wheel" "METADATA Name=$metadata_name" "$distribution" 'change package metadata before building; do not rename the wheel' + [ "$metadata_version" = "$wheel_version" ] || fail "$wheel" "METADATA Version=$metadata_version" "$wheel_version" 'align the version sources and rebuild' + case ".$platform." in *.linux_*.*) fail "$wheel" "$platform" 'repaired manylinux platform; no bare linux_*' 'run auditwheel repair excluding CUDA libraries' ;; esac + duplicate=$(jq -rs --arg file "$filename" '[.[] | select(.filename == $file)][0].path // empty' "$scratch/entries") + [ -z "$duplicate" ] || fail "$wheel" "duplicate $filename at $duplicate and $wheel" 'unique wheel filenames across selected cells' 'select one cell for each CUDA-major, Python/Ubuntu and architecture combination' + provenance_count=$(awk -v path="$PYPI_PROVENANCE_PATH" '$0 == path {n++} END {print n+0}' "$scratch/members") + [ "$provenance_count" -eq 1 ] || fail "$wheel" "$provenance_count $PYPI_PROVENANCE_PATH entries" 'one provenance file' 'write and package provenance before pip wheel' + unzip -p "$wheel" "$PYPI_PROVENANCE_PATH" > "$scratch/provenance" || fail "$wheel" 'unreadable provenance' 'JSON provenance' 'rebuild the wheel' + git_sha=$(jq -er '.git_sha | select(type == "string")' "$scratch/provenance") || fail "$wheel" 'missing or invalid git_sha' "$RELEASE_COMMIT" 'write JSON provenance before pip wheel' + [ "$git_sha" = "$RELEASE_COMMIT" ] || fail "$wheel" "git_sha=$git_sha" "$RELEASE_COMMIT" 'rebuild from the release commit' + cuda_major=$(printf '%s' "$distribution" | sed -n 's/.*-cu\([0-9][0-9]*\)$/\1/p') + if [ -n "$cuda_major" ]; then + cuda_version=$(jq -r '.cuda_version // empty' "$scratch/provenance") + [ "${cuda_version%%.*}" = "$cuda_major" ] || fail "$wheel" "cuda_version=$cuda_version" "CUDA major $cuda_major" 'build the named CUDA variant and regenerate provenance' + fi + bytes=$(wc -c < "$wheel" | tr -d ' ') + [ "$bytes" -le 1048576000 ] || fail "$wheel" "$bytes bytes" 'at most 1048576000 bytes (1000 MiB)' 'reduce the wheel size or arrange a different release strategy' + checksum=$(sha256sum "$wheel"); checksum=${checksum%% *} + jq -n --arg filename "$filename" --arg path "$wheel" --arg distribution "$distribution" --arg project_id "$project_id" --arg version "$wheel_version" --arg sha256 "$checksum" --argjson bytes "$bytes" '{filename:$filename,path:$path,distribution:$distribution,project_id:$project_id,version:$version,sha256:$sha256,bytes:$bytes}' >> "$scratch/entries" + done < "$scratch/files" + [ -s "$scratch/entries" ] || fail "$WHEEL_DIR" 'zero matching wheels' "$PYPI_PLATFORM" 'download the selected wheel artifacts and check the platform filter' + jq -r '.[] | [.artifact_dir,(.cuda | split(".")[0])] | @tsv' "$scratch/cells" > "$scratch/expected-cells" + while IFS="$(printf '\t')" read -r cell_dir cell_cuda; do + if [ "$cell_dir" = . ]; then cell_prefix="${WHEEL_DIR%/}/"; else cell_prefix="${WHEEL_DIR%/}/$cell_dir/"; fi + cell_count=$(jq -rs --arg prefix "$cell_prefix" --arg suffix "-cu$cell_cuda" '[.[] | select((.path | startswith($prefix)) and (.distribution | endswith($suffix)))] | length' "$scratch/entries") + [ "$cell_count" -eq 1 ] || fail "$cell_prefix" "$cell_count selected CUDA $cell_cuda wheels" 'exactly one finished wheel per selected matrix cell' 'restore the missing cell artifact or separate extra build outputs' + done < "$scratch/expected-cells" + jq -es --slurpfile cells "$scratch/cells" --arg root "${WHEEL_DIR%/}/" 'length == ($cells[0] | length) and all(.[]; . as $wheel | any($cells[0][]; (if .artifact_dir == "." then $root else $root + .artifact_dir + "/" end) as $prefix | $wheel.path | startswith($prefix)))' "$scratch/entries" >/dev/null || fail "$WHEEL_DIR" 'extra wheel or wheel outside selected cell directories' 'only the confirmed publish cells' 'narrow the artifact download selection' + jq -s --arg tag "$RELEASE_TAG" --arg commit "$RELEASE_COMMIT" --arg repository "$PYPI_PUBLISH_REPOSITORY" --arg source "$PYPI_ASSET_SOURCE" --arg plc "$PYPI_PER_RELEASE_PLC" '{template_version:"1.9.0",tag:$tag,commit:$commit,repository:$repository,asset_source:$source,per_release_plc:$plc,wheels:.}' "$scratch/entries" > "$manifest" + { + printf '| Wheel | Bytes | SHA256 |\n|---|---:|---|\n' + jq -r '.wheels[] | "| \(.filename) | \(.bytes) | \(.sha256) |"' "$manifest" + printf '\nApproval checklist (record in the approval comment):\n- [ ] IP self/peer review done\n- [ ] Artifacts and hashes above confirmed\n' + [ "$PYPI_PER_RELEASE_PLC" != yes ] || printf '%s\n' '- [ ] L1 Feature PLC complete' + } > "$scratch/summary" + cat "$scratch/summary" + if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then cat "$scratch/summary" >> "$GITHUB_STEP_SUMMARY"; fi +} + +poll_release() { + deadline=$(($(date +%s) + PYPI_TIMEOUT_SECONDS)) + retry_delay=$PYPI_POLL_SECONDS + max_retry_delay=60 + while :; do + poll_delay=$PYPI_POLL_SECONDS + http --header "Authorization: Bearer $KITMAKER_TOKEN" "$KITMAKER_API/status/$release_uuid" + cat "$scratch/response"; printf '\n' + if [ "$http_rc" -eq 0 ]; then + retry_delay=$PYPI_POLL_SECONDS + cp "$scratch/response" "pypi-status-$release_uuid.json" + state=$(jq -er '.status | select(type == "string" and length > 0)' "$scratch/response") || fail "$release_uuid" 'missing or invalid status' 'status JSON' 'inspect the saved response' + case "$state" in + completed) return 0 ;; + failed) + printf 'Batch outcomes (unlisted wheels may be unstarted; inspect both indexes):\n' + jq -r '.jobs[]? | "\(.status) \(.wheel_url)"' "$scratch/response" + cat "$scratch/completed" + fail "$release_uuid" 'failed or partially published batch' 'all wheels completed' 'inspect every wheel on both indexes; retain UUID; do not resubmit the batch' ;; + esac + else + case "$http_rc:$http_status" in + 5:*|6:*|7:*|18:*|28:*|52:*|55:*|56:*|22:408|22:429|22:5??) + poll_delay=$retry_delay + [ "$poll_delay" -le "$max_retry_delay" ] || poll_delay=$max_retry_delay + retry_delay=$((poll_delay * 2)) + state="transient HTTP $http_status (curl=$http_rc)" ;; + *) fail "$release_uuid" "HTTP $http_status, curl $http_rc" 'successful status response' 'inspect the response; check token and NVIDIA CA; do not resubmit' ;; + esac + fi + [ "$(date +%s)" -lt "$deadline" ] || fail "$release_uuid" "$state at polling deadline" 'terminal completed state' 'inspect saved status and both indexes; do not resubmit or infer cancellation' + printf 'POLL %s: %s; waiting %s seconds\n' "$release_uuid" "$state" "$poll_delay" + sleep "$poll_delay" + done +} +submit_projects() { + jq -r '.wheels[].project_id' "$manifest" | LC_ALL=C sort -u > "$scratch/ids" + : > "$scratch/completed" + while IFS= read -r id; do + jq --arg id "$id" --arg pic "$KITMAKER_PIC" --argjson upload "$upload" '.wheels | map(select(.project_id == $id)) | {project_name:.[0].distribution,payload:map({pic:$pic,job_type:"wheel-release-job",url:.url,upload:$upload,mirroring_strategy:"upload-both"})}' "$scratch/publish-manifest" > "$scratch/request" + kitmaker --request POST --data-binary "@$scratch/request" "$KITMAKER_API/projects/$id/releases" + release_uuid=$(jq -er '.release_uuid | select(type == "string" and test("^[A-Za-z0-9-]+$"))' "$scratch/response") || fail "$id" 'missing release_uuid' 'release UUID' 'inspect response; do not resubmit an uncertain request' + printf '%s %s %s\n' "$id" "$upload" "$release_uuid" >> pypi-release-uuids.txt + poll_release + jq -r --arg id "$id" --arg upload "$upload" '.wheels[] | select(.project_id == $id) | "COMPLETED upload=\($upload) \(.filename)"' "$manifest" >> "$scratch/completed" + cat "$scratch/completed" + done < "$scratch/ids" +} +verify_indexes() { + deadline=$(($(date +%s) + PYPI_INDEX_TIMEOUT_SECONDS)) + while :; do + missing=0 + while IFS="$(printf '\t')" read -r name version filename checksum; do + http "https://pypi.org/pypi/$name/$version/json" + if [ "$http_status" = 200 ] && [ "$http_rc" -eq 0 ] && jq -e --arg file "$filename" --arg sum "$checksum" 'any(.urls[]; .filename == $file and .digests.sha256 == $sum)' "$scratch/response" >/dev/null; then printf 'VERIFIED pypi.org %s %s\n' "$filename" "$checksum"; else missing=$((missing + 1)); printf 'WAIT pypi.org %s %s\n' "$filename" "$checksum"; cat "$scratch/response"; fi + http "https://pypi.nvidia.com/$name/" + if [ "$http_status" = 200 ] && [ "$http_rc" -eq 0 ] && grep -F "$filename#sha256=$checksum" "$scratch/response" >/dev/null; then printf 'VERIFIED pypi.nvidia.com %s %s\n' "$filename" "$checksum"; else missing=$((missing + 1)); printf 'WAIT pypi.nvidia.com %s %s\n' "$filename" "$checksum"; cat "$scratch/response"; fi + done < "$scratch/expected" + [ "$missing" -ne 0 ] || return 0 + [ "$(date +%s)" -lt "$deadline" ] || fail "$manifest" "$missing index entries missing or hash mismatch" 'every filename and SHA256 on both indexes' 'inspect indexes and saved UUIDs; do not resubmit' + sleep "$PYPI_POLL_SECONDS" + done +} +publish() { + required PYPI_PUBLISH_REPOSITORY PYPI_ASSET_SOURCE RELEASE_TAG RELEASE_COMMIT + repository_guard + required KITMAKER_TOKEN KITMAKER_PIC + for number in "$PYPI_POLL_SECONDS" "$PYPI_TIMEOUT_SECONDS" "$PYPI_INDEX_TIMEOUT_SECONDS"; do + case "$number" in ''|*[!0-9]*) fail "$0" "$number" 'positive integer deadline/interval' 'correct polling configuration' ;; esac + [ "$number" -gt 0 ] || fail "$0" "$number" 'positive polling value' 'correct polling configuration' + done + case "$PYPI_DRY_RUN_ONLY" in 0|1) ;; *) fail "$0" "$PYPI_DRY_RUN_ONLY" '0 or 1' 'set the dry-run switch explicitly' ;; esac + [ -f "$manifest" ] || fail "$manifest" 'missing' 'manifest from successful prepare' 'download the prepare artifact' + jq -e --arg tag "$RELEASE_TAG" --arg commit "$RELEASE_COMMIT" --arg repository "$PYPI_PUBLISH_REPOSITORY" --arg source "$PYPI_ASSET_SOURCE" ' + .template_version == "1.9.0" and .tag == $tag and .commit == $commit and .repository == $repository and .asset_source == $source and + (.wheels | type == "array" and length > 0 and all(.[]; (.filename | test("^[A-Za-z0-9_.!+-]+\\.whl$")) and (.sha256 | test("^[a-f0-9]{64}$")) and (.project_id | test("^[1-9][0-9]*$"))))' "$manifest" >/dev/null || fail "$manifest" 'invalid manifest or tag/commit/repository/source mismatch' "$RELEASE_TAG $RELEASE_COMMIT $PYPI_PUBLISH_REPOSITORY $PYPI_ASSET_SOURCE" 'use the manifest from this release and template version' + jq -r '.wheels[] | [.distribution,.version,.filename,.sha256] | @tsv' "$manifest" > "$scratch/expected" + jq -r '.wheels[] | [.path,.filename,.sha256] | @tsv' "$manifest" > "$scratch/to-publish" + : > "$scratch/urls" + while IFS="$(printf '\t')" read -r wheel filename checksum; do + tag_uri=$(printf '%s' "$RELEASE_TAG" | jq -sRr @uri) + file_uri=$(printf '%s' "$filename" | jq -sRr @uri) + url="https://github.com/$PYPI_PUBLISH_REPOSITORY/releases/download/$tag_uri/$file_uri" + http "$url" + if [ "$http_status" = 404 ]; then fail "$filename" 'HTTP 404' 'public GitHub release asset' "a draft release isn't readable; publish the draft before approval"; fi + [ "$http_rc" -eq 0 ] || fail "$filename" "HTTP $http_status curl=$http_rc" 'anonymous asset download' 'fix release asset visibility or URL' + actual=$(sha256sum "$scratch/response"); actual=${actual%% *} + [ "$actual" = "$checksum" ] || fail "$filename" "$actual" "$checksum" 'restore the exact immutable artifact; regenerate approval after any change' + jq -n --arg filename "$filename" --arg url "$url" '{filename:$filename,url:$url}' >> "$scratch/urls" + done < "$scratch/to-publish" + jq --slurpfile urls "$scratch/urls" '.wheels |= map(. as $wheel | . + ($urls[] | select(.filename == $wheel.filename)))' "$manifest" > "$scratch/publish-manifest" + upload=false + submit_projects + if [ "$PYPI_DRY_RUN_ONLY" = 1 ]; then printf 'DRY RUN complete; no Kitmaker upload submitted.\n'; return 0; fi + upload=true + submit_projects + verify_indexes +} +case "$mode" in prepare) prepare ;; publish) publish ;; esac diff --git a/scripts/release_pycunls.sh b/scripts/release_pycunls.sh new file mode 100755 index 0000000..235841c --- /dev/null +++ b/scripts/release_pycunls.sh @@ -0,0 +1,19 @@ +#!/bin/bash +# Publishes the built wheel filenames unchanged as public GitHub release assets. +set -euo pipefail + +: "${RELEASE_TAG:?Expected v}" +: "${RELEASE_COMMIT:?Expected the built checkout SHA}" +: "${GH_REPO:?Expected nvidia-isaac/cuNLS}" +[ "$GH_REPO" = nvidia-isaac/cuNLS ] || { echo "Only the canonical repository releases wheels." >&2; exit 1; } +[[ "$RELEASE_TAG" = v* ]] || { echo "Expected a v tag." >&2; exit 1; } + +sh "$(dirname "$0")/publish_pypi.sh" prepare +validated_paths=$(jq -er '.wheels[].path' pypi-manifest.json) +mapfile -t wheels <<< "$validated_paths" + +# Existing assets are immutable inputs to approval. Retry only pypi-publish +# after a dry run or a partial failure, rather than replacing release bytes. +gh release create "$RELEASE_TAG" "${wheels[@]}" --verify-tag \ + --target "$RELEASE_COMMIT" --title "cuNLS $RELEASE_TAG" \ + --notes "Built from commit $RELEASE_COMMIT. Wheels require the matching CUDA toolkit; the Python import remains pycunls." diff --git a/scripts/requirements-build.txt b/scripts/requirements-build.txt new file mode 100644 index 0000000..f4d1e7d --- /dev/null +++ b/scripts/requirements-build.txt @@ -0,0 +1,11 @@ +scikit-build-core>=0.10 +nanobind>=2.4 +build +wheel +pre-commit +auditwheel +# Ubuntu 22.04's patchelf 0.14.3 is below auditwheel's repair minimum. +patchelf>=0.14.5 +packaging +actionlint-py +shellcheck-py diff --git a/scripts/test_pycunls.sh b/scripts/test_pycunls.sh new file mode 100755 index 0000000..6b02641 --- /dev/null +++ b/scripts/test_pycunls.sh @@ -0,0 +1,12 @@ +#!/bin/bash +# Installs the exact wheel and its extras inside the CUDA image. +set -euo pipefail +shopt -s nullglob +wheels=(/cunls_install/*.whl) +if [ "${#wheels[@]}" -ne 1 ]; then + echo "Expected one wheel in /cunls_install; build into a separate directory for each matrix cell." >&2 + exit 1 +fi +pip install "${wheels[0]}[test]" +cd /cunls +python -m pytest python/tests/ --junitxml=/cunls_install/python-test-results.xml -v diff --git a/scripts/test_pycunls_in_docker.sh b/scripts/test_pycunls_in_docker.sh index a3119b8..a702e01 100755 --- a/scripts/test_pycunls_in_docker.sh +++ b/scripts/test_pycunls_in_docker.sh @@ -23,9 +23,4 @@ TTY_FLAG="" docker run --runtime=nvidia --gpus all --rm $TTY_FLAG \ -v "$(pwd):/cunls:ro" \ -v "$WHEEL_DIR:/cunls_install" \ - cunls:local bash -c ' - set -euo pipefail - pip install /cunls_install/*.whl "pycunls[test]" - cd /cunls - python -m pytest python/tests/ --junitxml=/cunls_install/python-test-results.xml -v - ' + cunls:local bash /cunls/scripts/test_pycunls.sh