diff --git a/.github/actions/windows-instance-state/action.yml b/.github/actions/windows-instance-state/action.yml new file mode 100644 index 000000000000..ead6e994436d --- /dev/null +++ b/.github/actions/windows-instance-state/action.yml @@ -0,0 +1,100 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +name: 'Windows instance state report + minimum cleanup' +description: >- + Print disk + relevant directory sizes on the Windows runner before and after + a job, and (in 'post' phase) wipe non-cache user state so the runner is left + net-zero except for content-addressed wheel caches (uv, pip). + See the per-dir comments below for what's cleaned and why. + +inputs: + phase: + description: '"pre" (report only) or "post" (report → cleanup → report).' + required: true + +runs: + using: composite + steps: + - name: Report and optionally clean + shell: powershell + run: | + $ErrorActionPreference = "Continue" + + # Directories we observe on every run. uv/pip are content-addressed + # wheel caches (safe across runs, big speedup). The rest is user state + # that can chain bad behavior between runs and is cleaned in 'post'. + $observed = [ordered]@{ + "uv cache" = "$env:LOCALAPPDATA\uv\cache" # KEEP — content-addressed + "pip cache" = "$env:LOCALAPPDATA\pip\Cache" # KEEP — content-addressed + "Kit shader cache" = "$env:LOCALAPPDATA\NVIDIA\Omniverse" # KEEP — invalidated by Kit on version mismatch + "Kit user state" = "$env:APPDATA\NVIDIA Corporation\Omniverse Kit" # CLEAN — settings + last-used renderer chain + "Kit docs" = "$env:USERPROFILE\Documents\Kit" # CLEAN — recent files / persistent_state + "user site-pkgs" = "$env:APPDATA\Python\Python312\site-packages" # observe — escaped pip --user installs + } + + function Show-State($label) { + Write-Host "=== Windows instance state: $label ===" + Get-PSDrive C | + Select-Object @{n='Drive';e={$_.Name}}, + @{n='Used GB';e={[math]::Round($_.Used/1GB,1)}}, + @{n='Free GB';e={[math]::Round($_.Free/1GB,1)}} | + Format-Table + # GPU presence check via nvidia-smi. Surfaces "is there a GPU at all" + # before any Kit boot, so a Vulkan failure can be classified as + # "no GPU on the runner" vs "GPU present but Vulkan/driver issue". + $nvsmi = & nvidia-smi --query-gpu=name,driver_version,memory.total --format=csv,noheader 2>&1 + if ($LASTEXITCODE -eq 0) { + Write-Host "nvidia-smi: $nvsmi" + } else { + Write-Host "nvidia-smi: not available or no GPU detected (exit=$LASTEXITCODE)" + } + foreach ($k in $observed.Keys) { + $p = $observed[$k] + if (Test-Path $p) { + $s = (Get-ChildItem -Recurse -File -ErrorAction SilentlyContinue $p | + Measure-Object -Sum Length -ErrorAction SilentlyContinue).Sum + if ($null -eq $s) { $s = 0 } + "{0,-20} {1,10:N1} MB ({2})" -f $k, ($s/1MB), $p + } else { + "{0,-20} {1,10} ({2})" -f $k, "(absent)", $p + } + } + } + + if ("${{ inputs.phase }}" -eq "pre") { + Show-State "BEFORE" + exit 0 + } + + # phase == post: report → minimum cleanup → report + Show-State "AFTER (pre-cleanup)" + + $toRemove = @( + "$env:APPDATA\NVIDIA Corporation\Omniverse Kit", + "$env:USERPROFILE\Documents\Kit" + ) + foreach ($p in $toRemove) { + if (Test-Path $p) { + Remove-Item -LiteralPath $p -Recurse -Force -ErrorAction SilentlyContinue + } + } + # Crash-leftover scratch dirs in %TEMP%. + Get-ChildItem -Path $env:TEMP -Filter "Kit*" -ErrorAction SilentlyContinue | + Remove-Item -Recurse -Force -ErrorAction SilentlyContinue + Get-ChildItem -Path $env:TEMP -Filter "hub-*" -ErrorAction SilentlyContinue | + Remove-Item -Recurse -Force -ErrorAction SilentlyContinue + Get-ChildItem -Path $env:TEMP -Filter "omniverse-*" -ErrorAction SilentlyContinue | + Remove-Item -Recurse -Force -ErrorAction SilentlyContinue + # build.sh fallback can leave 'build' / 'wheel' in user site-packages. + $userSite = "$env:APPDATA\Python\Python312\site-packages" + foreach ($pkg in @("build", "wheel")) { + $p = Join-Path $userSite $pkg + if (Test-Path $p) { + Remove-Item -LiteralPath $p -Recurse -Force -ErrorAction SilentlyContinue + } + } + + Show-State "AFTER (post-cleanup)" diff --git a/.github/actions/windows-sim-paths/action.yml b/.github/actions/windows-sim-paths/action.yml new file mode 100644 index 000000000000..2a5bc92b45d3 --- /dev/null +++ b/.github/actions/windows-sim-paths/action.yml @@ -0,0 +1,104 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +name: 'Resolve Isaac Sim paths and DLL search dirs on Windows' +description: >- + Discover the active Isaac Sim install root (via pip show isaacsim-kernel), + export ISAAC_PATH / CARB_APP_PATH / EXP_PATH / RESOURCE_NAME to subsequent + steps in this job, and prepend Isaac Sim's bin + kit/plugins directories to + PATH so the Vulkan loader can find NVIDIA's ICD DLLs and Kit can find its + plugin DLLs. + Mirrors PR #4018's known-working Windows env setup so Kit's RTX path can + initialise on a self-hosted Windows runner where DLL search defaults are not + pointed at the Sim install. + +inputs: + venv-path: + description: 'Path to the uv/python venv whose site-packages contains isaacsim (relative to workspace).' + required: false + default: 'env_isaaclab_uv' + +runs: + using: composite + steps: + - name: Resolve Isaac Sim paths + shell: powershell + run: | + $ErrorActionPreference = "Stop" + # Re-activate the caller's venv inside this fresh PowerShell session + # so `python -m pip show isaacsim-kernel` finds the right interpreter. + $activate = Join-Path "${{ inputs.venv-path }}" "Scripts\Activate.ps1" + if (-not (Test-Path $activate)) { throw "venv activate not found at $activate" } + & $activate + # Discover Sim location from pip metadata (avoids `import isaacsim`, + # which would bootstrap the kernel and is the exact thing we are about + # to launch deliberately). + # Use `uv pip show` rather than `python -m pip show` since uv venvs are + # created without pip installed inside the venv by default. + # Capture stdout only; uv writes its banner ("Using Python ...") to + # stderr and merging with 2>&1 trips $ErrorActionPreference=Stop via + # PowerShell's NativeCommandError handling on stderr lines. + $pipShow = (uv pip show isaacsim-kernel) | Out-String + if ($LASTEXITCODE -ne 0) { $pipShow = "" } + $loc = $pipShow -split "`n" | Where-Object { $_ -match "^Location:" } | Select-Object -First 1 + if (-not $loc) { + $pipShow = (uv pip show isaacsim) | Out-String + if ($LASTEXITCODE -ne 0) { $pipShow = "" } + $loc = $pipShow -split "`n" | Where-Object { $_ -match "^Location:" } | Select-Object -First 1 + } + if (-not $loc) { throw "Could not resolve isaacsim install path from pip" } + $sitePackages = ($loc -split "Location: ", 2)[1].Trim() + + # The Sim root is either ${sitePackages}\isaacsim or a versioned + # ${sitePackages}\isaacsim-* dir. Pick whichever holds kit/ or apps/. + $candidates = @() + $candidates += Join-Path $sitePackages "isaacsim" + $candidates += Join-Path $sitePackages "isaacsim_kernel" + Get-ChildItem -Path $sitePackages -Directory -Filter "isaacsim-*" -ErrorAction SilentlyContinue | + ForEach-Object { $candidates += $_.FullName } + + $isaacRoot = $null + foreach ($c in $candidates) { + if (Test-Path $c) { + if ((Test-Path (Join-Path $c "kit")) -or (Test-Path (Join-Path $c "apps"))) { + $isaacRoot = $c + break + } + } + } + if (-not $isaacRoot) { + Write-Host "Searched candidates for Sim root:" + $candidates | ForEach-Object { Write-Host " - $_ (exists: $(Test-Path $_))" } + throw "Could not find Isaac Sim install (no kit/ or apps/ under any candidate)" + } + + $carb = Join-Path $isaacRoot "kit" + # EXP_PATH should point at IsaacLab's workspace apps dir if present + # (IsaacLab ships its own .kit files there); fall back to Sim's apps. + $workspaceApps = Join-Path $PWD "apps" + $expPath = if (Test-Path $workspaceApps) { $workspaceApps } else { Join-Path $isaacRoot "apps" } + + # Export to subsequent steps via $GITHUB_ENV. + "ISAAC_PATH=$isaacRoot" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + "CARB_APP_PATH=$carb" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + "EXP_PATH=$expPath" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + "RESOURCE_NAME=IsaacSim" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + + # Prepend Sim DLL search dirs to PATH so the Vulkan loader can find + # NVIDIA's ICD .json + .dll and Kit can resolve plugin DLLs. + $extra = @() + foreach ($d in @((Join-Path $carb "plugins"), (Join-Path $isaacRoot "bin"))) { + if (Test-Path $d) { $extra += $d } + } + if ($extra.Count -gt 0) { + $newPath = ($extra -join ";") + ";" + $env:PATH + "PATH=$newPath" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + } + + Write-Host "Resolved:" + Write-Host " ISAAC_PATH = $isaacRoot" + Write-Host " CARB_APP_PATH = $carb" + Write-Host " EXP_PATH = $expPath" + Write-Host " PATH prepend = $($extra -join ';')" diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index a121db583ef2..3ba7fb60cb61 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -74,7 +74,10 @@ jobs: name: Detect Changes runs-on: ubuntu-latest outputs: - run_docker_tests: ${{ steps.detect.outputs.run_docker_tests }} + # TEMP (revert before final review): force run_docker_tests=false while + # iterating Windows CI on PR #5700. Saves runner time + cost during the + # back-and-forth. + run_docker_tests: 'false' steps: - id: detect env: diff --git a/.github/workflows/install-ci.yml b/.github/workflows/install-ci.yml index 2a2fee50c8ab..1a715b8d2fa6 100644 --- a/.github/workflows/install-ci.yml +++ b/.github/workflows/install-ci.yml @@ -35,7 +35,10 @@ jobs: name: Detect Changes runs-on: ubuntu-latest outputs: - run_install_tests: ${{ steps.detect.outputs.run_install_tests }} + # TEMP (revert before final review): force run_install_tests=false while + # iterating Windows CI on PR #5700. Saves runner time + cost during the + # back-and-forth. + run_install_tests: 'false' steps: - id: detect env: diff --git a/.github/workflows/license-check.yaml b/.github/workflows/license-check.yaml index 3598e8381a17..8cc853dad0ce 100644 --- a/.github/workflows/license-check.yaml +++ b/.github/workflows/license-check.yaml @@ -15,6 +15,9 @@ concurrency: jobs: license-check: + # TEMP (revert before final review): skipped while iterating Windows CI on + # PR #5700. Saves runner time + cost during the back-and-forth. + if: false runs-on: ubuntu-24.04 steps: diff --git a/.github/workflows/test-multi-gpu.yaml b/.github/workflows/test-multi-gpu.yaml index becb5961cf57..beb34614b57c 100644 --- a/.github/workflows/test-multi-gpu.yaml +++ b/.github/workflows/test-multi-gpu.yaml @@ -30,6 +30,10 @@ concurrency: jobs: test-multi-gpu: name: Multi-GPU (${{ matrix.physics }}, ${{ matrix.renderer }}) + # TEMP (revert before final review): skipped while iterating Windows CI on + # PR #5700 (this PR touches app_launcher.py, which would otherwise trigger + # the multi-GPU self-hosted runners). Saves runner time + cost. + if: false # Use dedicated multi-GPU runner to avoid blocking standard CI resources # Configure this label on a runner with 2+ GPUs (e.g., g5.12xlarge with 4x A10G) runs-on: [self-hosted, linux, x64, gpu, multi-gpu] diff --git a/.github/workflows/wheel.yml b/.github/workflows/wheel.yml index be8e1bbb558d..f0d06397a376 100644 --- a/.github/workflows/wheel.yml +++ b/.github/workflows/wheel.yml @@ -39,6 +39,14 @@ jobs: run: | set -euo pipefail + # TEMP (revert before final review): force run_build=false while + # iterating Windows CI on PR #5700. The detect step still runs so the + # required check stays green; only the heavy build steps are skipped. + echo "run_build=false" >> "$GITHUB_OUTPUT" + echo "## Wheel build gating" >> "$GITHUB_STEP_SUMMARY" + echo "Skipped: TEMP disabled while iterating Windows CI (PR #5700)." >> "$GITHUB_STEP_SUMMARY" + exit 0 + # Keep this workflow unconditionally triggered on PRs so required # branch-protection checks are always reported. The build steps below # run only when inputs that can affect the wheel have changed. diff --git a/.github/workflows/windows-ci.yaml b/.github/workflows/windows-ci.yaml new file mode 100644 index 000000000000..4042da8a68b8 --- /dev/null +++ b/.github/workflows/windows-ci.yaml @@ -0,0 +1,401 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +# Windows CI on self-hosted GPU runners. Single consolidated job so the +# autoscaler can't tear the runner between sub-jobs. Kit-launching steps +# use Start-Process + WaitForExit as an OS-level watchdog (Python thread +# watchdogs are GIL-vulnerable). pytest uses --timeout-method=thread +# (SIGALRM is Unix-only). + +name: Windows CI + +on: + pull_request: + types: [opened, synchronize, reopened] + branches: + - main + - develop + - 'release/**' + push: + branches: + - main + - develop + - 'release/**' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: write + checks: write + +# EULA + headless env. Without these Kit bootstrap blocks on stdin +# ("Unable to bootstrap inner kit kernel: EOF when reading a line") or +# the global watchdog kills the headless process. Mirrors PR #4018. +env: + OMNI_KIT_ACCEPT_EULA: "yes" + ACCEPT_EULA: "Y" + ISAACSIM_ACCEPT_EULA: "YES" + PRIVACY_CONSENT: "Y" + HEADLESS: "1" + ISAAC_SIM_HEADLESS: "1" + ISAAC_SIM_LOW_MEMORY: "1" + WINDOWS_PLATFORM: "true" + OMNI_KIT_NO_WINDOW: "1" + OMNI_KIT_DISABLE_WATCHDOG: "1" + OMNI_KIT_TELEMETRY: "0" + CARB_LOGGING_SEVERITY: "error" + PYTHONUNBUFFERED: "1" + PYTHONIOENCODING: "utf-8" + +jobs: + changes: + name: Detect Changes + runs-on: ubuntu-latest + outputs: + run_windows_ci: ${{ steps.detect.outputs.run_windows_ci }} + steps: + - id: detect + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + EVENT_NAME: ${{ github.event_name }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + patterns=( + $'^source/\tLibrary source code' + $'^tools/\tBuild tooling' + $'^apps/\tStandalone apps' + $'(^|/)pyproject\\.toml$\tPython project metadata' + $'^\\.github/workflows/windows-ci\\.yaml$\tThis workflow file' + $'^VERSION$\tVersion file' + ) + any_match() { + local files="$1" entry regex + for entry in "${patterns[@]}"; do + IFS=$'\t' read -r regex _ <<< "$entry" + if grep -qE "$regex" <<< "$files"; then + return 0 + fi + done + return 1 + } + if [ "$EVENT_NAME" != "pull_request" ]; then + echo "run_windows_ci=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + changed_files="$(gh api --paginate "repos/$REPO/pulls/$PR_NUMBER/files" --jq '.[].filename' || true)" + if [ -z "$changed_files" ] || any_match "$changed_files"; then + echo "run_windows_ci=true" >> "$GITHUB_OUTPUT" + else + echo "run_windows_ci=false" >> "$GITHUB_OUTPUT" + fi + + windows-ci: + name: windows-ci + needs: [changes] + if: needs.changes.outputs.run_windows_ci == 'true' + runs-on: [self-hosted, gpu-windows] + timeout-minutes: 90 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 1 + lfs: false + + - name: Report instance state (BEFORE) + uses: ./.github/actions/windows-instance-state + with: { phase: pre } + + - name: Install uv + shell: powershell + run: | + $ErrorActionPreference = "Stop" + if (-not (Get-Command uv -ErrorAction SilentlyContinue)) { + irm https://astral.sh/uv/install.ps1 | iex + } + Add-Content -Path $env:GITHUB_PATH -Value "$HOME\.local\bin" + + # Shared setup. Hard fail aborts the job (no continue-on-error) since + # downstream steps all depend on this venv. + - name: Setup venv + install develop-aligned Isaac Sim + isaaclab + test deps + id: setup + shell: powershell + timeout-minutes: 25 + env: + # Read-only service-account credentials for the internal Isaac Sim + # Artifactory index (anonymous access was removed). The resolver reads + # these from the environment; the uv install builds authenticated index + # URLs from them below. Same secrets the Linux/ARM CI uses to reach the + # internal develop registry. + ISAACSIM_ARTIFACTORY_READONLY_USERNAME: ${{ secrets.ISAACSIM_ARTIFACTORY_READONLY_USERNAME }} + ISAACSIM_ARTIFACTORY_READONLY_PASSWORD: ${{ secrets.ISAACSIM_ARTIFACTORY_READONLY_PASSWORD }} + # Optional. When set, the resolver verifies the picked build's commit is + # on omni_isaac_sim develop (and can read the private repo). When absent + # or gitlab is unreachable, it falls back to the newest 6.0.0 build with + # a warning (see tools/resolve_isaacsim_develop.py --allow-unverified). + GITLAB_TOKEN: ${{ secrets.GITLAB_TOKEN }} + run: | + $ErrorActionPreference = "Stop" + if (-not $env:ISAACSIM_ARTIFACTORY_READONLY_USERNAME -or -not $env:ISAACSIM_ARTIFACTORY_READONLY_PASSWORD) { + throw "ISAACSIM_ARTIFACTORY_READONLY_USERNAME/PASSWORD secrets are not set; cannot reach the internal Isaac Sim index" + } + # --seed because the wheel-builder step runs `python -m pip install build wheel`. + uv venv --python 3.12 --seed env_isaaclab_uv + & "env_isaaclab_uv\Scripts\Activate.ps1" + uv pip install pytest pytest-timeout h5py + + # Authenticated index URLs. URL-encode the credentials so special + # characters survive; GitHub masks the secret values and uv redacts + # credentials when echoing index URLs. + $u = [uri]::EscapeDataString($env:ISAACSIM_ARTIFACTORY_READONLY_USERNAME) + $p = [uri]::EscapeDataString($env:ISAACSIM_ARTIFACTORY_READONLY_PASSWORD) + $swIndex = "https://${u}:${p}@urm.nvidia.com/artifactory/api/pypi/sw-isaacsim-pypi/simple" + $ctIndex = "https://${u}:${p}@urm.nvidia.com/artifactory/api/pypi/ct-omniverse-pypi/simple" + + # Pull Isaac Sim from the internal Artifactory index at the build aligned + # with omni_isaac_sim develop, so native Windows tests the same Sim as the + # Linux/ARM develop container instead of the older public pip release. + # The resolver authenticates via the ISAACSIM_ARTIFACTORY_READONLY_* env vars. + $pin = python tools/resolve_isaacsim_develop.py ` + --index-url "https://urm.nvidia.com/artifactory/api/pypi/sw-isaacsim-pypi/simple/isaacsim/" ` + --python-tag cp312 --platform-tag win_amd64 ` + --verify-branch develop --version-prefix 6.0.0 --allow-unverified + if ($LASTEXITCODE -ne 0) { throw "resolve_isaacsim_develop.py failed (exit $LASTEXITCODE)" } + $pin = "$pin".Trim() + if (-not $pin) { throw "no develop-aligned Isaac Sim version resolved" } + Write-Host "Resolved develop-aligned Isaac Sim: $pin" + uv pip install --pre "isaacsim[all,extscache]==$pin" ` + --extra-index-url $swIndex ` + --extra-index-url $ctIndex + + # Install IsaacLab WITHOUT the 'isaacsim' extra: it hard-pins the public + # release (==5.1.0), which would conflict with the develop build above. + .\isaaclab.bat -i 'rl[rsl_rl,rl_games]' + python -c "import importlib.metadata as m; print('isaacsim build:', m.version('isaacsim'))" + python -c "import isaaclab, isaaclab_assets, isaaclab_tasks, isaaclab_newton, isaaclab_physx, isaaclab_ppisp; print('editable imports ok')" + New-Item -ItemType Directory -Force -Path "reports" | Out-Null + + - name: Resolve Isaac Sim paths (ISAAC_PATH / CARB_APP_PATH / EXP_PATH / DLL search) + id: sim-paths + uses: ./.github/actions/windows-sim-paths + with: { venv-path: 'env_isaaclab_uv' } + + # ===== Test branches (each independent, continue-on-error). ===== + + - name: Deps smoke (torch + scipy) + id: test-deps + if: always() && steps.setup.outcome == 'success' + continue-on-error: true + shell: powershell + timeout-minutes: 5 + run: | + $ErrorActionPreference = "Stop" + & "env_isaaclab_uv\Scripts\Activate.ps1" + python -m pytest ` + source/isaaclab/test/deps ` + --ignore=tools/conftest.py ` + -m windows_ci ` + --continue-on-collection-errors ` + --timeout=60 ` + --timeout-method=thread ` + -v ` + --junitxml=reports/deps-smoke.xml + + - name: Path-IO tests (utils) + id: test-pathio + if: always() && steps.setup.outcome == 'success' + continue-on-error: true + shell: powershell + timeout-minutes: 10 + run: | + $ErrorActionPreference = "Stop" + & "env_isaaclab_uv\Scripts\Activate.ps1" + # Explicit files only; neighbor tests import AppLauncher/argparse at + # module load and crash on Windows without Sim initialised. + python -m pytest ` + source/isaaclab/test/utils/test_configclass.py ` + source/isaaclab/test/utils/test_dict.py ` + source/isaaclab/test/utils/test_episode_data.py ` + source/isaaclab/test/utils/test_hdf5_dataset_file_handler.py ` + --continue-on-collection-errors ` + --timeout=120 ` + --timeout-method=thread ` + -v ` + --junitxml=reports/path-io.xml + + - name: Kit headless boot smoke + id: test-kit-launch + if: always() && steps.sim-paths.outcome == 'success' + continue-on-error: true + shell: powershell + timeout-minutes: 8 + run: | + $ErrorActionPreference = "Stop" + & "env_isaaclab_uv\Scripts\Activate.ps1" + $script = @' + import sys + from isaaclab.app import AppLauncher + + app_launcher = AppLauncher(headless=True) + sim = app_launcher.app + assert sim is not None, "AppLauncher did not return a SimulationApp" + sim.close() + sys.exit(0) + '@ + $script | Out-File -FilePath kit_launch_smoke.py -Encoding utf8 + $proc = Start-Process -PassThru -NoNewWindow -FilePath python -ArgumentList "kit_launch_smoke.py" + if (-not $proc.WaitForExit(300000)) { + Write-Host "::error::kit-launch hard timeout (5 min) - Kit hung; killing python tree" + Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue + $proc.WaitForExit() + exit 124 + } + exit $proc.ExitCode + + # Runs both subcases: state (rsl_rl) and perception (rl_games, + # --enable_cameras). The perception subcase needs the runner GPU in + # WDDM mode so Kit's RTX/Vulkan path initialises; the data-center (TCC) + # driver does not expose Vulkan. + - name: Cartpole training smoke (state + perception) + id: test-training-smoke + if: always() && steps.sim-paths.outcome == 'success' + continue-on-error: true + shell: powershell + timeout-minutes: 25 + run: | + $ErrorActionPreference = "Stop" + & "env_isaaclab_uv\Scripts\Activate.ps1" + python -m pytest ` + source/isaaclab_tasks/test/test_cartpole_training_smoke.py ` + --continue-on-collection-errors ` + --timeout=600 ` + --timeout-method=thread ` + -v ` + --junitxml=reports/training-smoke.xml + + # Cartpole-camera perception smoke — exercises Kit's RTX/Vulkan path + # directly (lighter than the perception training subcase, so a Vulkan + # init failure surfaces here first). Needs the runner GPU in WDDM mode + # so Kit can enumerate a Vulkan device; the data-center (TCC) driver + # does not expose Vulkan. + - name: Cartpole-camera perception smoke (RTX / Vulkan path) + id: test-perception + if: always() && steps.sim-paths.outcome == 'success' + continue-on-error: true + shell: powershell + timeout-minutes: 8 + run: | + $ErrorActionPreference = "Stop" + & "env_isaaclab_uv\Scripts\Activate.ps1" + $script = @' + import sys + from isaaclab.app import AppLauncher + app_launcher = AppLauncher(headless=True, enable_cameras=True) + sim = app_launcher.app + assert sim is not None, "AppLauncher did not return a SimulationApp" + import gymnasium as gym + import isaaclab_tasks # noqa: F401 (gym env registration) + env = gym.make("Isaac-Cartpole-RGB-Camera-Direct-v0", num_envs=1) + obs, info = env.reset() + assert obs is not None, "env.reset returned None observation" + for step_i in range(3): + action = env.action_space.sample() + obs, reward, terminated, truncated, info = env.step(action) + assert obs is not None, f"env.step {step_i} returned None observation" + env.close() + sim.close() + sys.exit(0) + '@ + $script | Out-File -FilePath perception_smoke.py -Encoding utf8 + $proc = Start-Process -PassThru -NoNewWindow -FilePath python -ArgumentList "perception_smoke.py" + if (-not $proc.WaitForExit(180000)) { + Write-Host "::error::perception hard timeout (3 min) - Kit/Vulkan hung; killing python tree" + Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue + $proc.WaitForExit() + exit 124 + } + exit $proc.ExitCode + + # Last test step — destructively uninstalls editable isaaclab and + # reinstalls from the built wheel. Placed last so the test branches + # above run against the editable install. + - name: Wheel build + reinstall + smoke import + id: test-wheel-build + if: always() && steps.setup.outcome == 'success' + continue-on-error: true + shell: powershell + timeout-minutes: 20 + run: | + $ErrorActionPreference = "Stop" + & "env_isaaclab_uv\Scripts\Activate.ps1" + $gitBash = "C:\Program Files\Git\bin\bash.exe" + if (-not (Test-Path $gitBash)) { throw "Git Bash not found at $gitBash" } + # git-bash on Windows ships `python` only, not `python3`. + $env:PYTHON = "python" + & $gitBash tools/wheel_builder/build.sh + if ($LASTEXITCODE -ne 0) { throw "wheel_builder/build.sh failed with exit $LASTEXITCODE" } + $wheel = Get-ChildItem -Path "tools/wheel_builder/build/dist" -Filter "isaaclab-*.whl" | Select-Object -First 1 + if (-not $wheel) { throw "no wheel found in tools/wheel_builder/build/dist" } + uv pip uninstall isaaclab + # No '[all]' extra: it pins isaacsim==5.1.0, which would downgrade the + # develop Isaac Sim installed in setup. The develop build stays in place; + # this only validates that the freshly built isaaclab wheel installs. + uv pip install "$($wheel.FullName)" + python -c "import isaaclab; print('wheel install ok:', isaaclab.__file__)" + + # ===== Reporting + cleanup. ===== + + - name: Upload all test reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: windows-ci-reports + path: | + reports/ + kit_launch_smoke.py + perception_smoke.py + retention-days: 7 + if-no-files-found: ignore + + # Every active test step gates the job. + - name: Aggregate test results + if: always() + shell: powershell + run: | + $results = [ordered]@{ + "setup" = "${{ steps.setup.outcome }}" + "sim-paths" = "${{ steps.sim-paths.outcome }}" + "deps" = "${{ steps.test-deps.outcome }}" + "path-io" = "${{ steps.test-pathio.outcome }}" + "kit-launch" = "${{ steps.test-kit-launch.outcome }}" + "training-smoke" = "${{ steps.test-training-smoke.outcome }}" + "perception" = "${{ steps.test-perception.outcome }}" + "wheel-build" = "${{ steps.test-wheel-build.outcome }}" + } + Write-Host "=== windows-ci step outcomes ===" + foreach ($k in $results.Keys) { + "{0,-16} {1}" -f $k, $results[$k] + } + $blocking = @("setup", "sim-paths", "deps", "path-io", "kit-launch", "training-smoke", "perception", "wheel-build") + $failed = @() + foreach ($k in $blocking) { + if ($results[$k] -eq "failure") { $failed += $k } + } + if ($failed.Count -gt 0) { + Write-Host "::error::Failing job - these steps failed: $($failed -join ', ')" + exit 1 + } + Write-Host "All gating steps passed." + + - name: Report instance state + cleanup (AFTER) + if: always() + uses: ./.github/actions/windows-instance-state + with: { phase: post } diff --git a/pyproject.toml b/pyproject.toml index a64fc84af215..2991ee91068b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -196,6 +196,10 @@ ignore-words-list = "haa,slq,collapsable,buss,reacher,thirdparty" markers = [ "isaacsim_ci: mark test to run in isaacsim ci", + "windows: mark test as runnable on Windows platforms", + "windows_ci: mark test to run on Windows platforms in CI", + "arm: mark test as runnable on ARM platforms (e.g. NVIDIA DGX Spark)", + "arm_ci: mark test to run on ARM platforms in CI (e.g. NVIDIA DGX Spark)", "device_split: re-invoke this file once per device (CPU and GPU) in CI due to process-global device locks (e.g., ovphysx<=0.3.7 gap G5)", ] diff --git a/source/isaaclab/changelog.d/jichuanh-windows-ci.skip b/source/isaaclab/changelog.d/jichuanh-windows-ci.skip new file mode 100644 index 000000000000..3ae87e252fbd --- /dev/null +++ b/source/isaaclab/changelog.d/jichuanh-windows-ci.skip @@ -0,0 +1 @@ +Skip changelog: CI-infrastructure only (no user-facing API change). Adds .github/workflows/windows-ci.yaml carrying the Windows CI pipeline against [self-hosted, gpu-windows] runners. Tier 1 (smoke, install probe with wheel build + reinstall, Kit launch) plus Tier 2 (path-IO marker-driven discovery, cartpole-camera perception smoke). All jobs use continue-on-error: true and pytest --timeout to fail fast on hangs. Inline scripts assert explicitly and exit nonzero on any failure (fixes the previous pattern where Vulkan failures hung the job instead of erroring). diff --git a/source/isaaclab/changelog.d/jichuanh-windows-spark-ci-min.skip b/source/isaaclab/changelog.d/jichuanh-windows-spark-ci-min.skip new file mode 100644 index 000000000000..bfa2b75a780a --- /dev/null +++ b/source/isaaclab/changelog.d/jichuanh-windows-spark-ci-min.skip @@ -0,0 +1 @@ +Skip changelog: CI/test-infrastructure foundation (no user-facing API change). Registers the windows / windows_ci / arm / arm_ci pytest markers in pyproject.toml, teaches AppLauncher to recognize them in argv so they do not leak into Isaac Sim's argparse, and moves the AssetConverterBase USD scratch dir from hardcoded /tmp/IsaacLab to tempfile.gettempdir() for cross-platform compatibility. Workflow files (arm-ci.yaml, windows-ci.yaml) ship in follow-up PRs. diff --git a/source/isaaclab/isaaclab/app/app_launcher.py b/source/isaaclab/isaaclab/app/app_launcher.py index e8ff526496a1..711b1a5abd04 100644 --- a/source/isaaclab/isaaclab/app/app_launcher.py +++ b/source/isaaclab/isaaclab/app/app_launcher.py @@ -1180,12 +1180,18 @@ def _create_app(self): sys.stdout = open(os.devnull, "w") # noqa: SIM115 # pytest may have left some things in sys.argv, this will check for some of those - # do a mark and sweep to remove any -m pytest and -m isaacsim_ci and -c **/pyproject.toml + # do a mark and sweep to remove any -m pytest, -m isaacsim_ci, -m windows_ci, -m arm_ci, + # and -c **/pyproject.toml indexes_to_remove = [] for idx, arg in enumerate(sys.argv[:-1]): if arg == "-m": value_for_dash_m = sys.argv[idx + 1] - if "pytest" in value_for_dash_m or "isaacsim_ci" in value_for_dash_m: + if ( + "pytest" in value_for_dash_m + or "isaacsim_ci" in value_for_dash_m + or "windows_ci" in value_for_dash_m + or "arm_ci" in value_for_dash_m + ): indexes_to_remove.append(idx) indexes_to_remove.append(idx + 1) if arg.startswith("--config-file=") and "pyproject.toml" in arg: diff --git a/source/isaaclab/isaaclab/sim/converters/asset_converter_base.py b/source/isaaclab/isaaclab/sim/converters/asset_converter_base.py index 11c200422391..703ef202e2a7 100644 --- a/source/isaaclab/isaaclab/sim/converters/asset_converter_base.py +++ b/source/isaaclab/isaaclab/sim/converters/asset_converter_base.py @@ -9,6 +9,7 @@ import os import pathlib import random +import tempfile from datetime import datetime from isaaclab.sim.converters.asset_converter_base_cfg import AssetConverterBaseCfg @@ -34,9 +35,10 @@ class AssetConverterBase(abc.ABC): can be set to True. When no output directory is defined, lazy conversion is deactivated and the generated USD file is - stored in folder ``/tmp/IsaacLab/usd_{date}_{time}_{random}``, where the parameters in braces are generated - at runtime. The random identifiers help avoid a race condition where two simultaneously triggered conversions - try to use the same directory for reading/writing the generated files. + stored in folder ``/IsaacLab/usd_{date}_{time}_{random}``, where ```` is the system + temporary directory (e.g. ``/tmp`` on POSIX, ``%TEMP%`` on Windows) and the parameters in braces are + generated at runtime. The random identifiers help avoid a race condition where two simultaneously + triggered conversions try to use the same directory for reading/writing the generated files. .. note:: Changes to the parameters :obj:`AssetConverterBaseCfg.asset_path`, :obj:`AssetConverterBaseCfg.usd_dir`, and @@ -64,9 +66,9 @@ def __init__(self, cfg: AssetConverterBaseCfg): # resolve USD directory name if cfg.usd_dir is None: - # a folder in "/tmp/IsaacLab" by the name: usd_{date}_{time}_{random} + # a folder in the system temp dir by the name: IsaacLab/usd_{date}_{time}_{random} time_tag = datetime.now().strftime("%Y%m%d_%H%M%S") - self._usd_dir = f"/tmp/IsaacLab/usd_{time_tag}_{random.randrange(10000)}" + self._usd_dir = os.path.join(tempfile.gettempdir(), "IsaacLab", f"usd_{time_tag}_{random.randrange(10000)}") else: self._usd_dir = cfg.usd_dir diff --git a/source/isaaclab/test/deps/test_scipy.py b/source/isaaclab/test/deps/test_scipy.py index d697716aad7a..f42e54c304e9 100644 --- a/source/isaaclab/test/deps/test_scipy.py +++ b/source/isaaclab/test/deps/test_scipy.py @@ -13,6 +13,8 @@ import numpy as np import scipy.interpolate as interpolate +pytestmark = [pytest.mark.windows_ci, pytest.mark.arm_ci] + @pytest.mark.isaacsim_ci def test_interpolation(): diff --git a/source/isaaclab/test/deps/test_torch.py b/source/isaaclab/test/deps/test_torch.py index 6a50110757de..e651987daa26 100644 --- a/source/isaaclab/test/deps/test_torch.py +++ b/source/isaaclab/test/deps/test_torch.py @@ -7,6 +7,8 @@ import torch import torch.utils.benchmark as benchmark +pytestmark = [pytest.mark.windows_ci, pytest.mark.arm_ci] + @pytest.mark.isaacsim_ci def test_array_slicing(): diff --git a/source/isaaclab/test/utils/test_configclass.py b/source/isaaclab/test/utils/test_configclass.py index 1c2f13c1ef1c..f23d99498d22 100644 --- a/source/isaaclab/test/utils/test_configclass.py +++ b/source/isaaclab/test/utils/test_configclass.py @@ -16,6 +16,8 @@ import torch from isaaclab.utils.configclass import _field_module_dir, configclass + +pytestmark = pytest.mark.windows_ci from isaaclab.utils.dict import class_to_dict, dict_to_md5_hash, update_class_from_dict from isaaclab.utils.io import dump_yaml, load_yaml from isaaclab.utils.string import ResolvableString diff --git a/source/isaaclab/test/utils/test_dict.py b/source/isaaclab/test/utils/test_dict.py index b2cbd8bb0e6d..3b54d5177f01 100644 --- a/source/isaaclab/test/utils/test_dict.py +++ b/source/isaaclab/test/utils/test_dict.py @@ -10,6 +10,8 @@ import isaaclab.utils.dict as dict_utils import isaaclab.utils.string as string_utils +pytestmark = pytest.mark.windows_ci + def _test_function(x): """Test function for string <-> callable conversion.""" diff --git a/source/isaaclab/test/utils/test_episode_data.py b/source/isaaclab/test/utils/test_episode_data.py index a2d570d9d6ef..567ecd747626 100644 --- a/source/isaaclab/test/utils/test_episode_data.py +++ b/source/isaaclab/test/utils/test_episode_data.py @@ -7,6 +7,8 @@ from isaaclab.utils.datasets import EpisodeData +pytestmark = pytest.mark.windows_ci + @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) def test_is_empty(device): diff --git a/source/isaaclab/test/utils/test_hdf5_dataset_file_handler.py b/source/isaaclab/test/utils/test_hdf5_dataset_file_handler.py index 11e8a434b1ac..a0c76c56c799 100644 --- a/source/isaaclab/test/utils/test_hdf5_dataset_file_handler.py +++ b/source/isaaclab/test/utils/test_hdf5_dataset_file_handler.py @@ -3,7 +3,6 @@ # # SPDX-License-Identifier: BSD-3-Clause import os -import shutil import tempfile import uuid @@ -12,6 +11,8 @@ from isaaclab.utils.datasets import EpisodeData, HDF5DatasetFileHandler +pytestmark = pytest.mark.windows_ci + def create_test_episode(device): """create a test episode with dummy data.""" @@ -36,10 +37,12 @@ def create_test_episode(device): @pytest.fixture def temp_dir(): """Create a temporary directory for test datasets.""" - temp_dir = tempfile.mkdtemp() - yield temp_dir - # cleanup after tests - shutil.rmtree(temp_dir) + # ignore_cleanup_errors absorbs a Windows-specific PermissionError: + # libhdf5 keeps an internal file handle briefly after .close(), and + # rmtree races with that handle release. On Linux/macOS this flag is + # a no-op since no cleanup error is raised. + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as d: + yield d def test_create_dataset_file(temp_dir): diff --git a/source/isaaclab_tasks/changelog.d/jichuanh-windows-ci.skip b/source/isaaclab_tasks/changelog.d/jichuanh-windows-ci.skip new file mode 100644 index 000000000000..8dad5a2f809c --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/jichuanh-windows-ci.skip @@ -0,0 +1 @@ +Skip changelog: CI/test-only (no user-facing API change). Adds source/isaaclab_tasks/test/test_cartpole_training_smoke.py — a minimal cartpole training smoke (state rsl_rl + perception rl_games, two PPO iters each) tagged with the arm_ci and windows_ci markers so cross-platform CI shapes can invoke it via marker-driven discovery. diff --git a/source/isaaclab_tasks/test/test_cartpole_training_smoke.py b/source/isaaclab_tasks/test/test_cartpole_training_smoke.py new file mode 100644 index 000000000000..2542b5e8af61 --- /dev/null +++ b/source/isaaclab_tasks/test/test_cartpole_training_smoke.py @@ -0,0 +1,81 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Minimal end-to-end training smoke for cartpole. + +Two cases — state-only and perception (RGB tiled camera) — each spawn a +``scripts/reinforcement_learning//train.py`` for two PPO iterations +on a small env count. They validate the full pipeline (``./isaaclab.sh`` +wrapper, gym registration, env build, RL wrapper, optimizer step, checkpoint +write) without the cost of a real training run, so the orchestrator can +include them in every CI shape (Linux, ARM/Spark). + +The state case uses rsl_rl (matches Isaac-Cartpole-Direct-v0's registered +config entry); the perception case uses rl_games because the camera-variant +direct envs only register ``rl_games_cfg_entry_point``. +""" + +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + +import pytest + +# Cross-platform: ARM (Linux/aarch64) and Windows CI both opt in. +pytestmark = [pytest.mark.arm_ci, pytest.mark.windows_ci] + +_REPO_ROOT = Path(__file__).resolve().parents[3] +# isaaclab.bat on Windows, isaaclab.sh on Linux/macOS — same CLI surface. +_LAUNCHER = str(_REPO_ROOT / ("isaaclab.bat" if os.name == "nt" else "isaaclab.sh")) + + +def _run_train(train_script: str, task_name: str, extra_args: list[str] | None = None, timeout: int = 600) -> None: + """Spawn a trainer for two iterations and assert it exits cleanly.""" + cmd = [ + _LAUNCHER, + "-p", + train_script, + "--task", + task_name, + "--headless", + "--num_envs", + "16", + "--max_iterations", + "2", + "--seed", + "42", + ] + if extra_args: + cmd.extend(extra_args) + + result = subprocess.run( + cmd, + cwd=_REPO_ROOT, + text=True, + capture_output=True, + timeout=timeout, + check=False, + ) + assert result.returncode == 0, ( + f"Training command failed for {task_name}: {' '.join(cmd)}\n" + f"--- stdout (tail) ---\n{result.stdout[-4000:]}\n" + f"--- stderr (tail) ---\n{result.stderr[-4000:]}\n" + ) + + +def test_train_cartpole_state(): + """State-observation cartpole trains for two rsl_rl PPO iterations without errors.""" + _run_train("scripts/reinforcement_learning/rsl_rl/train.py", "Isaac-Cartpole-Direct-v0") + + +def test_train_cartpole_perception(): + """RGB-camera cartpole trains for two rl_games PPO iterations without errors.""" + _run_train( + "scripts/reinforcement_learning/rl_games/train.py", + "Isaac-Cartpole-RGB-Camera-Direct-v0", + extra_args=["--enable_cameras"], + ) diff --git a/tools/resolve_isaacsim_develop.py b/tools/resolve_isaacsim_develop.py new file mode 100644 index 000000000000..38ee0b5e4123 --- /dev/null +++ b/tools/resolve_isaacsim_develop.py @@ -0,0 +1,315 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Resolve the Isaac Sim wheel version aligned with the ``omni_isaac_sim`` develop branch. + +The native (non-Docker) install paths -- e.g. the Windows CI -- pull Isaac Sim +from a pip index, whereas the Linux/ARM CI runs inside the internal develop +container. To keep the native path on the same develop build instead of the +older public release, this tool: + +1. reads the PEP 503 *simple* index page for the ``isaacsim`` project on the + internal Artifactory registry, +2. selects the newest pre-release wheel built for the requested Python/platform + tag (the index also carries release-line builds, so newest alone is not a + proof of provenance), and +3. optionally verifies that the selected build's embedded git commit is on the + ``omni_isaac_sim`` develop branch -- the actual "is this develop?" check -- + walking from newest to older until one verifies, + +then prints the full version string (e.g. ``6.0.0rc48+release.40557.63231095.gl``) +on stdout for use in ``uv pip install --pre "isaacsim[all,extscache]=="``. + +Everything except :func:`_http_get` and :func:`commit_on_branch` is pure and +unit tested in ``tools/test_resolve_isaacsim_develop.py``. Progress/warnings go +to stderr so the resolved version is the only thing on stdout. +""" + +from __future__ import annotations + +import argparse +import base64 +import json +import os +import re +import sys +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import dataclass + +# Wheel filename: isaacsim----.whl. The +# version segment carries no '-', so a greedy non-'-' run captures it (incl. the +# PEP 440 local segment '+release...gl'). +_WHEEL_RE = re.compile( + r"isaacsim-(?P[^-]+)-(?P[^-]+)-(?P[^-]+)-(?P[^-/\"<>]+)\.whl", + re.IGNORECASE, +) +# Local build segment baked into internal builds: +release...gl +_BUILD_RE = re.compile(r"\+release\.(?P\d+)\.(?P[0-9a-fA-F]+)\.gl") + + +@dataclass(frozen=True) +class IsaacSimWheel: + """A single ``isaacsim`` wheel parsed from a simple-index page. + + Attributes: + version: Full PEP 440 version, e.g. ``6.0.0rc48+release.40557.63231095.gl``. + python_tag: CPython tag from the filename, e.g. ``cp312``. + platform_tag: Platform tag from the filename, e.g. ``win_amd64``. + build: Monotonic Isaac Sim build number from the local segment, or ``None`` + for a public build that carries no ``+release....`` segment. + commit: ``omni_isaac_sim`` git short SHA from the local segment, or ``None``. + """ + + version: str + python_tag: str + platform_tag: str + build: int | None + commit: str | None + + +def parse_simple_index(html: str) -> list[IsaacSimWheel]: + """Parse a PEP 503 simple-index page into the ``isaacsim`` wheels it lists. + + Args: + html: Raw HTML of the simple-index project page. URL-encoded ``+`` (``%2B``) + in hrefs is tolerated by unquoting before matching. + + Returns: + One :class:`IsaacSimWheel` per distinct wheel filename, in page order. + """ + text = urllib.parse.unquote(html) + wheels: list[IsaacSimWheel] = [] + seen: set[str] = set() + for match in _WHEEL_RE.finditer(text): + filename = match.group(0) + if filename in seen: + continue + seen.add(filename) + version = match.group("version") + build_match = _BUILD_RE.search(version) + wheels.append( + IsaacSimWheel( + version=version, + python_tag=match.group("py").lower(), + platform_tag=match.group("plat").lower(), + build=int(build_match.group("build")) if build_match else None, + commit=build_match.group("sha").lower() if build_match else None, + ) + ) + return wheels + + +def select_candidates( + wheels: list[IsaacSimWheel], + python_tag: str, + platform_tag: str, + version_prefix: str | None = None, +) -> list[IsaacSimWheel]: + """Internal builds matching one Python/platform tag, newest build first. + + Public wheels (no ``+release.`` segment) are excluded since only the + internal builds track the develop branch. + + Args: + wheels: Parsed wheels from :func:`parse_simple_index`. + python_tag: Required CPython tag, e.g. ``cp312``. + platform_tag: Required platform tag, e.g. ``win_amd64``. + version_prefix: Optional ``str.startswith`` filter on the version, used as + a coarse develop-line heuristic (e.g. ``6.0.0``) when branch + verification is unavailable. + + Returns: + Matching wheels sorted by descending build number (the monotonic CI + counter, the most reliable "latest develop" ordering). + """ + python_tag = python_tag.lower() + platform_tag = platform_tag.lower() + out = [ + w + for w in wheels + if w.python_tag == python_tag + and w.platform_tag == platform_tag + and w.build is not None + and (version_prefix is None or w.version.startswith(version_prefix)) + ] + out.sort(key=lambda w: w.build or 0, reverse=True) # builds are filtered non-None above + return out + + +def _basic_auth_header(username: str, password: str) -> str: + """Return the value for an HTTP ``Authorization: Basic`` header for the given credentials.""" + encoded = base64.b64encode(f"{username}:{password}".encode()).decode("ascii") + return f"Basic {encoded}" + + +def _http_get( + url: str, + token: str | None = None, + basic_auth: tuple[str, str] | None = None, + timeout: float = 30.0, +) -> str: + """GET ``url`` and return the decoded body. Raises on network/HTTP error.""" + headers = {"User-Agent": "isaaclab-ci-resolve"} + if token: + headers["PRIVATE-TOKEN"] = token + # The internal Artifactory index dropped anonymous access, so the simple-index + # fetch now needs the read-only service-account credentials (see windows-ci.yaml). + if basic_auth is not None: + headers["Authorization"] = _basic_auth_header(*basic_auth) + request = urllib.request.Request(url, headers=headers) + with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310 (trusted internal URL) + charset = response.headers.get_content_charset() or "utf-8" + return response.read().decode(charset, errors="replace") + + +def commit_on_branch( + gitlab_base: str, + project: str, + commit: str, + branch: str, + token: str | None = None, + timeout: float = 30.0, +) -> bool | None: + """Whether ``commit`` is on ``branch`` of the gitlab ``project``. + + Args: + gitlab_base: gitlab base URL, e.g. ``https://gitlab-master.nvidia.com``. + project: URL path of the project, e.g. ``omniverse/isaac/omni_isaac_sim``. + commit: Full or short commit SHA to look up. + branch: Branch name to require, e.g. ``develop``. + token: gitlab access token (``PRIVATE-TOKEN``); required for private repos. + timeout: Per-request timeout in seconds. + + Returns: + ``True``/``False`` when the answer is known, or ``None`` when gitlab could + not be reached or the response was unusable (caller decides how to degrade). + """ + encoded_project = urllib.parse.quote(project, safe="") + url = ( + f"{gitlab_base.rstrip('/')}/api/v4/projects/{encoded_project}" + f"/repository/commits/{commit}/refs?type=branch&per_page=100" + ) + try: + body = _http_get(url, token=token, timeout=timeout) + except (urllib.error.URLError, OSError): + return None + try: + refs = json.loads(body) + except ValueError: + return None + if not isinstance(refs, list): + return None + return any(isinstance(ref, dict) and ref.get("name") == branch for ref in refs) + + +def main(argv: list[str] | None = None) -> int: + """CLI entry point. Prints the resolved version on success; see module docstring.""" + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument( + "--index-url", + action="append", + required=True, + metavar="URL", + help="simple-index 'isaacsim' project page URL (repeatable).", + ) + parser.add_argument( + "--index-username", + default=os.environ.get("ISAACSIM_ARTIFACTORY_READONLY_USERNAME"), + help="basic-auth username for the index (default: $ISAACSIM_ARTIFACTORY_READONLY_USERNAME).", + ) + parser.add_argument( + "--index-password", + default=os.environ.get("ISAACSIM_ARTIFACTORY_READONLY_PASSWORD"), + help="basic-auth password for the index (default: $ISAACSIM_ARTIFACTORY_READONLY_PASSWORD).", + ) + parser.add_argument("--python-tag", default="cp312", help="required CPython tag (default: cp312).") + parser.add_argument("--platform-tag", default="win_amd64", help="required platform tag (default: win_amd64).") + parser.add_argument( + "--version-prefix", + default=None, + help="coarse develop-line filter (e.g. 6.0.0); also the fallback when branch verify is unavailable.", + ) + parser.add_argument( + "--verify-branch", + default=None, + metavar="BRANCH", + help="require the build's commit to be on this omni_isaac_sim branch (e.g. develop).", + ) + parser.add_argument("--gitlab-base", default="https://gitlab-master.nvidia.com") + parser.add_argument("--gitlab-project", default="omniverse/isaac/omni_isaac_sim") + parser.add_argument( + "--gitlab-token", + default=os.environ.get("GITLAB_TOKEN"), + help="gitlab token for branch verification (default: $GITLAB_TOKEN).", + ) + parser.add_argument("--max-verify", type=int, default=10, help="max newest builds to branch-check (default: 10).") + parser.add_argument( + "--allow-unverified", + action="store_true", + help="if gitlab is unreachable, fall back to the newest version-prefix build with a warning.", + ) + args = parser.parse_args(argv) + + # Both credentials must be present to authenticate; otherwise fetch anonymously. + index_auth = (args.index_username, args.index_password) if args.index_username and args.index_password else None + + wheels: list[IsaacSimWheel] = [] + for url in args.index_url: + try: + wheels.extend(parse_simple_index(_http_get(url, basic_auth=index_auth))) + except (urllib.error.URLError, OSError) as exc: + print(f"warning: failed to fetch {url}: {exc}", file=sys.stderr) + + candidates = select_candidates(wheels, args.python_tag, args.platform_tag, args.version_prefix) + if not candidates: + print( + f"error: no isaacsim {args.python_tag}/{args.platform_tag} builds found on the given index" + f"{f' matching {args.version_prefix}*' if args.version_prefix else ''}", + file=sys.stderr, + ) + return 2 + + if not args.verify_branch: + print(candidates[0].version) + return 0 + + for wheel in candidates[: args.max_verify]: + verdict = commit_on_branch( + args.gitlab_base, args.gitlab_project, wheel.commit or "", args.verify_branch, token=args.gitlab_token + ) + if verdict is True: + print(f"verified {wheel.version} on '{args.verify_branch}'", file=sys.stderr) + print(wheel.version) + return 0 + if verdict is None: + # gitlab unreachable / unusable response -> stop probing, decide fallback. + if args.allow_unverified: + print( + f"warning: could not reach gitlab to verify '{args.verify_branch}'; falling back to newest" + f"{f' {args.version_prefix}' if args.version_prefix else ''} build {candidates[0].version}" + " (UNVERIFIED).", + file=sys.stderr, + ) + print(candidates[0].version) + return 0 + print( + f"error: could not reach gitlab to verify '{args.verify_branch}'; " + "pass --allow-unverified to proceed on the version-prefix heuristic.", + file=sys.stderr, + ) + return 3 + # verdict is False -> this build is not on the branch; try the next older one. + + print( + f"error: none of the newest {args.max_verify} isaacsim builds are on '{args.verify_branch}'", + file=sys.stderr, + ) + return 4 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/test_resolve_isaacsim_develop.py b/tools/test_resolve_isaacsim_develop.py new file mode 100644 index 000000000000..b39e55eec833 --- /dev/null +++ b/tools/test_resolve_isaacsim_develop.py @@ -0,0 +1,95 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Unit tests for the pure parsing/selection logic of resolve_isaacsim_develop. + +Only the network-free functions are covered here; ``_http_get`` and +``commit_on_branch`` touch the network and are exercised on a real runner. +""" + +from __future__ import annotations + +import base64 + +import resolve_isaacsim_develop as r + +# Synthetic wheels listed on a PEP 503 simple-index page. Mixes: newest+older +# develop win builds, a develop linux build (different platform), a release-line +# win build (cp311), and a public-stable win build (no +release segment). +_WHEELS = [ + "isaacsim-6.0.0rc48+release.40557.63231095.gl-cp312-none-win_amd64.whl", + "isaacsim-6.0.0rc47+release.40001.aaaaaaaa.gl-cp312-none-win_amd64.whl", + "isaacsim-6.0.0rc48+release.40557.63231095.gl-cp312-none-manylinux_2_35_x86_64.whl", + "isaacsim-5.1.0rc17+release.26116.14247817.gl-cp311-none-win_amd64.whl", + "isaacsim-5.1.0.0-cp311-none-win_amd64.whl", +] + + +def _index_html(wheels: list[str]) -> str: + """Render an anchor-per-wheel simple-index page; the first href %2B-encodes '+'.""" + rows = [f'{w}
' for i, w in enumerate(wheels)] + return "\n" + "\n".join(rows) + "\n" + + +_INDEX_HTML = _index_html(_WHEELS) + + +def test_parse_extracts_version_platform_build_and_commit(): + wheels = r.parse_simple_index(_INDEX_HTML) + # five distinct wheels, deduplicated and order-preserving + assert len(wheels) == 5 + newest = wheels[0] + assert newest.version == "6.0.0rc48+release.40557.63231095.gl" + assert newest.python_tag == "cp312" + assert newest.platform_tag == "win_amd64" + assert newest.build == 40557 + assert newest.commit == "63231095" + + +def test_parse_unquotes_percent_encoded_plus_in_href(): + # the win_amd64 rc48 wheel is listed once, but its href %2B-encodes '+' while + # its link text uses '+'; after unquoting, both collapse to one filename and + # dedup to a single entry (the same version also exists as a separate linux wheel) + wheels = r.parse_simple_index(_INDEX_HTML) + win_rc48 = [ + w for w in wheels if w.version == "6.0.0rc48+release.40557.63231095.gl" and w.platform_tag == "win_amd64" + ] + assert len(win_rc48) == 1 + + +def test_public_stable_build_has_no_build_or_commit(): + public = next(w for w in r.parse_simple_index(_INDEX_HTML) if w.version == "5.1.0.0") + assert public.build is None + assert public.commit is None + + +def test_select_picks_newest_build_for_platform_and_excludes_others(): + wheels = r.parse_simple_index(_INDEX_HTML) + cands = r.select_candidates(wheels, "cp312", "win_amd64") + # only the two develop win_amd64/cp312 builds, newest build first + assert [w.version for w in cands] == [ + "6.0.0rc48+release.40557.63231095.gl", + "6.0.0rc47+release.40001.aaaaaaaa.gl", + ] + + +def test_select_excludes_public_builds_without_build_segment(): + wheels = r.parse_simple_index(_INDEX_HTML) + cands = r.select_candidates(wheels, "cp311", "win_amd64") + # the cp311 win matches are the release-line rc (kept) and public 5.1.0.0 (dropped) + assert [w.version for w in cands] == ["5.1.0rc17+release.26116.14247817.gl"] + + +def test_select_version_prefix_filters_release_line(): + wheels = r.parse_simple_index(_INDEX_HTML) + assert r.select_candidates(wheels, "cp312", "win_amd64", version_prefix="5.1.0") == [] + assert len(r.select_candidates(wheels, "cp312", "win_amd64", version_prefix="6.0.0")) == 2 + + +def test_basic_auth_header_is_rfc7617_encoded(): + header = r._basic_auth_header("svc-user", "s3cr3t") + scheme, _, token = header.partition(" ") + assert scheme == "Basic" + assert base64.b64decode(token).decode() == "svc-user:s3cr3t" diff --git a/tools/wheel_builder/build.sh b/tools/wheel_builder/build.sh index 030cf537489a..a869d5354fdf 100755 --- a/tools/wheel_builder/build.sh +++ b/tools/wheel_builder/build.sh @@ -1,6 +1,10 @@ #!/bin/bash set -e +# Python interpreter override. Linux installs typically expose `python3`; +# Windows git-bash only has `python`. Callers can set PYTHON=python to override. +PYTHON="${PYTHON:-python3}" + SELF_DIR="$(dirname "$(realpath "$0")")" cd "$SELF_DIR/../.." @@ -86,20 +90,20 @@ cp "$SELF_DIR/res/__init__.py" "$BUILD_DIR/src/isaaclab/" cp "$SELF_DIR/res/__main__.py" "$BUILD_DIR/src/isaaclab/" # 3. Generate pyproject.toml with dependencies from python_packages.toml -python3 "$SELF_DIR/gen_pyproject.py" "$SELF_DIR/res/python_packages.toml" "$BUILD_DIR/pyproject.toml" "$WHEEL_VERSION" +"$PYTHON" "$SELF_DIR/gen_pyproject.py" "$SELF_DIR/res/python_packages.toml" "$BUILD_DIR/pyproject.toml" "$WHEEL_VERSION" # 4. Build the wheel cd "$BUILD_DIR" # Prefer --user to avoid polluting system Python; fall back to --break-system-packages # for environments where --user is unsupported (e.g. Docker, ephemeral CI runners). -python3 -m pip install --user build wheel 2>/dev/null || python3 -m pip install --break-system-packages build wheel -python3 -m build --wheel --outdir "$DIST_DIR/" +"$PYTHON" -m pip install --user build wheel 2>/dev/null || "$PYTHON" -m pip install --break-system-packages build wheel +"$PYTHON" -m build --wheel --outdir "$DIST_DIR/" # 5. Retag the wheel to match official platform tags # cd "$DIST_DIR" # GENERIC_WHL=$(ls isaaclab-*.whl) # echo "Retagging $GENERIC_WHL -> $PYTHON_TAG-$ABI_TAG-$PLATFORM_TAG" -# python3 -m wheel tags --python-tag "$PYTHON_TAG" --abi-tag "$ABI_TAG" --platform-tag "$PLATFORM_TAG" "$GENERIC_WHL" +# "$PYTHON" -m wheel tags --python-tag "$PYTHON_TAG" --abi-tag "$ABI_TAG" --platform-tag "$PLATFORM_TAG" "$GENERIC_WHL" # # Remove the generic wheel (wheel tags creates a new file) # TAGGED_WHL=$(ls isaaclab-*"$PLATFORM_TAG"*.whl 2>/dev/null) # if [ "$GENERIC_WHL" != "$TAGGED_WHL" ] && [ -n "$TAGGED_WHL" ]; then