diff --git a/.github/workflows/code-scanning.yaml b/.github/workflows/code-scanning.yaml index daaddca19a..fca6dc4913 100644 --- a/.github/workflows/code-scanning.yaml +++ b/.github/workflows/code-scanning.yaml @@ -54,110 +54,17 @@ jobs: - name: Install ShellCheck run: sudo apt-get update && sudo apt-get install -y shellcheck - - name: Collect shell files - id: shell-files - run: | - git ls-files '*.sh' 'install.sh' 'uninstall.sh' | sort -u > shell-files.txt - if [ -s shell-files.txt ]; then - echo "has_files=true" >> "$GITHUB_OUTPUT" - else - echo "has_files=false" >> "$GITHUB_OUTPUT" - fi + - name: Setup Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version: "22" - name: Generate ShellCheck SARIF - if: steps.shell-files.outputs.has_files == 'true' - run: | - # Ubuntu's packaged ShellCheck may not support --format=sarif. - # Generate json1 and convert it to SARIF for upload. - sc_exit=0 - if xargs -r shellcheck --format=json1 < shell-files.txt > shellcheck.json; then - sc_exit=0 - else - sc_exit=$? - fi - - if jq -e . shellcheck.json >/dev/null 2>&1; then - jq ' - def level_map: - if . == "error" then "error" - elif . == "warning" then "warning" - else "note" - end; - { - "version": "2.1.0", - "$schema": "https://json.schemastore.org/sarif-2.1.0.json", - "runs": [ - { - "tool": { - "driver": { - "name": "ShellCheck", - "informationUri": "https://www.shellcheck.net/", - "rules": ( - .comments - | map(select(.code != null) | { - "id": ("SC" + (.code | tostring)), - "name": ("SC" + (.code | tostring)), - "shortDescription": { "text": .level } - }) | unique_by(.id) - ) - } - }, - "results": ( - .comments - | map({ - "ruleId": ("SC" + (.code | tostring)), - "level": (.level | level_map), - "message": { "text": .message }, - "locations": [ - { - "physicalLocation": { - "artifactLocation": { "uri": .file }, - "region": ( - { - "startLine": .line, - "startColumn": .column, - "endLine": .endLine, - "endColumn": .endColumn - } | with_entries(select(.value != null)) - ) - } - } - ] - }) - ) - } - ] - } - ' shellcheck.json > shellcheck.sarif - else - echo "ShellCheck produced invalid JSON (exit=$sc_exit); writing empty SARIF fallback." - cat > shellcheck.sarif <<'EOF' - { - "version": "2.1.0", - "$schema": "https://json.schemastore.org/sarif-2.1.0.json", - "runs": [] - } - EOF - fi - - if [ "$sc_exit" -ne 0 ]; then - echo "ShellCheck reported findings (exit=$sc_exit); continuing so SARIF can be uploaded." - fi - - - name: Check SARIF has runs - id: sarif-runs - if: steps.shell-files.outputs.has_files == 'true' - run: | - run_count="$(jq '.runs | length' shellcheck.sarif)" - if [ "$run_count" -gt 0 ]; then - echo "has_runs=true" >> "$GITHUB_OUTPUT" - else - echo "has_runs=false" >> "$GITHUB_OUTPUT" - echo "Skipping SARIF upload because shellcheck.sarif has zero runs." - fi + id: shellcheck-sarif + run: node --no-warnings --experimental-strip-types scripts/github/shellcheck-sarif.ts - name: Upload ShellCheck SARIF - if: steps.shell-files.outputs.has_files == 'true' && steps.sarif-runs.outputs.has_runs == 'true' + if: steps.shellcheck-sarif.outputs.has_files == 'true' && steps.shellcheck-sarif.outputs.has_runs == 'true' uses: github/codeql-action/upload-sarif@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4 with: sarif_file: shellcheck.sarif diff --git a/.github/workflows/docs-links-pr.yaml b/.github/workflows/docs-links-pr.yaml index 5c5be2ac76..b1a825cebd 100644 --- a/.github/workflows/docs-links-pr.yaml +++ b/.github/workflows/docs-links-pr.yaml @@ -26,35 +26,17 @@ jobs: with: fetch-depth: 0 + - name: Setup Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version: "22" + - name: Determine changed documentation files id: changed - shell: bash - run: | - set -euo pipefail - base="${{ github.event.pull_request.base.sha }}" - head="${{ github.event.pull_request.head.sha }}" - mapfile -t doc_files < <( - git diff --name-only --diff-filter=ACMR "$base" "$head" -- \ - '*.md' \ - '*.mdx' \ - ':(exclude)node_modules/**' \ - ':(exclude)dist/**' \ - ':(exclude)vendor/**' \ - ':(exclude)build/**' \ - | LC_ALL=C sort -u - ) - - if [[ "${#doc_files[@]}" -eq 0 ]]; then - echo "has_files=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - echo "has_files=true" >> "$GITHUB_OUTPUT" - { - echo "files<> "$GITHUB_OUTPUT" + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: node --no-warnings --experimental-strip-types scripts/github/changed-docs.ts - name: Run markdown link checker if: steps.changed.outputs.has_files == 'true' diff --git a/.github/workflows/docs-preview-pr.yaml b/.github/workflows/docs-preview-pr.yaml index df6afe1ba2..2ac92bf098 100644 --- a/.github/workflows/docs-preview-pr.yaml +++ b/.github/workflows/docs-preview-pr.yaml @@ -57,6 +57,8 @@ jobs: working-directory: ./fern run: fern check + # Keep secret-bearing preview publishing inline in this workflow. + # Do not run PR-owned helper code with FERN_TOKEN or write-capable GH_TOKEN. - name: Generate preview URL if: ${{ steps.fern-preview.outputs.enabled == 'true' }} id: generate-docs @@ -90,8 +92,8 @@ jobs: ${MARKER}" - COMMENT_ID=$(gh api "repos/${{ github.repository }}/issues/${PR_NUMBER}/comments" \ - --jq ".[] | select(.body | contains(\"${MARKER}\")) | .id" | head -1) + COMMENT_ID=$(gh api --paginate --slurp "repos/${{ github.repository }}/issues/${PR_NUMBER}/comments" | + jq -r --arg marker "$MARKER" '[.[][]] | map(select(.body | contains($marker))) | .[0].id // empty') if [ -n "$COMMENT_ID" ]; then gh api "repos/${{ github.repository }}/issues/comments/${COMMENT_ID}" \ diff --git a/.github/workflows/e2e-scenarios.yaml b/.github/workflows/e2e-scenarios.yaml index 7832ef7d26..71f75db79f 100644 --- a/.github/workflows/e2e-scenarios.yaml +++ b/.github/workflows/e2e-scenarios.yaml @@ -93,6 +93,12 @@ jobs: node-version: 22 cache: npm + - name: Set up Node on Windows host + if: startsWith(inputs.scenario, 'wsl-') + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version: 22 + - name: Install root dependencies if: ${{ !startsWith(inputs.scenario, 'wsl-') }} run: npm ci --ignore-scripts @@ -147,125 +153,43 @@ jobs: - name: Resolve workspace paths for WSL if: startsWith(inputs.scenario, 'wsl-') - shell: powershell - run: | - $winPath = "${{ github.workspace }}" - $drive = $winPath.Substring(0,1).ToLower() - $rest = $winPath.Substring(2).Replace('\','/') - $wslCheckoutPath = "/mnt/$drive$rest" - $wslWorkdir = "/tmp/nemoclaw-scenario-wsl/${env:GITHUB_RUN_ID}-${env:GITHUB_RUN_ATTEMPT}" - "WSL_CHECKOUT_DIR=$wslCheckoutPath" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - "WSL_WORKDIR=$wslWorkdir" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + env: + WSL_WORKDIR_PREFIX: /tmp/nemoclaw-scenario-wsl + run: node --no-warnings --experimental-strip-types scripts/github/wsl.ts resolve-paths - name: Ensure Ubuntu WSL exists if: startsWith(inputs.scenario, 'wsl-') - shell: powershell - run: | - wsl --list --verbose 2>&1 | Out-Default - $null = wsl -d $env:WSL_DISTRO -- echo ok 2>&1 - if ($LASTEXITCODE -ne 0) { - wsl --install -d $env:WSL_DISTRO --no-launch --web-download - wsl -d $env:WSL_DISTRO -- bash -c 'echo distro initialised' - } - wsl --set-default $env:WSL_DISTRO + run: node --no-warnings --experimental-strip-types scripts/github/wsl.ts ensure-ubuntu - name: Install WSL dependencies if: startsWith(inputs.scenario, 'wsl-') - shell: powershell - run: | - $script = @' - set -euo pipefail - export DEBIAN_FRONTEND=noninteractive - printf '%s\n' 'Acquire::ForceIPv4 "true";' 'Acquire::Retries "5";' >/etc/apt/apt.conf.d/99github-actions-network - apt-get update - apt-get install -y bash ca-certificates curl git jq lsb-release make python3 python3-pip rsync tar unzip xz-utils - if ! docker info >/dev/null 2>&1; then - apt-get install -y docker.io - service docker start || /etc/init.d/docker start || true - timeout 30 bash -c 'until docker info >/dev/null 2>&1; do sleep 2; done' - fi - curl -fsSL https://deb.nodesource.com/setup_22.x | bash - - apt-get install -y nodejs - node --version - npm --version - docker --version - docker info >/dev/null - '@ - $tmp = "$env:RUNNER_TEMP\wsl-step.sh" - [IO.File]::WriteAllText($tmp, ($script -replace "`r",""), (New-Object System.Text.UTF8Encoding $false)) - $wslTmp = wsl -d $env:WSL_DISTRO -- wslpath -u ($tmp -replace '\\','/') - wsl -d $env:WSL_DISTRO -- bash -l $wslTmp + env: + WSL_INSTALL_DOCKER: "1" + run: node --no-warnings --experimental-strip-types scripts/github/wsl.ts install-ubuntu-deps + + - name: Install Node.js 22 in WSL + if: startsWith(inputs.scenario, 'wsl-') + run: node --no-warnings --experimental-strip-types scripts/github/wsl.ts install-node - name: Copy checkout into WSL ext4 workspace if: startsWith(inputs.scenario, 'wsl-') - shell: powershell - run: | - $script = @" - set -euo pipefail - rm -rf '$env:WSL_WORKDIR' - mkdir -p /tmp/nemoclaw-scenario-wsl - rsync -a --no-owner --no-group --delete --exclude '/node_modules/' --exclude '/nemoclaw/node_modules/' --exclude '/nemoclaw-blueprint/.venv/' '$env:WSL_CHECKOUT_DIR'/ '$env:WSL_WORKDIR'/ - git config --global --add safe.directory '$env:WSL_WORKDIR' - git -C '$env:WSL_WORKDIR' reset --hard HEAD - git -C '$env:WSL_WORKDIR' clean -ffdx - "@ - $tmp = "$env:RUNNER_TEMP\wsl-step.sh" - [IO.File]::WriteAllText($tmp, ($script -replace "`r",""), (New-Object System.Text.UTF8Encoding $false)) - $wslTmp = wsl -d $env:WSL_DISTRO -- wslpath -u ($tmp -replace '\\','/') - wsl -d $env:WSL_DISTRO -- bash -l $wslTmp + run: node --no-warnings --experimental-strip-types scripts/github/wsl.ts copy-checkout - name: Install root dependencies in WSL if: startsWith(inputs.scenario, 'wsl-') - shell: powershell - run: | - $script = @" - set -euo pipefail - cd '$env:WSL_WORKDIR' - npm ci --ignore-scripts - mkdir -p .e2e - bash test/e2e/runtime/coverage-report.sh > .e2e/coverage.md - "@ - $tmp = "$env:RUNNER_TEMP\wsl-step.sh" - [IO.File]::WriteAllText($tmp, ($script -replace "`r",""), (New-Object System.Text.UTF8Encoding $false)) - $wslTmp = wsl -d $env:WSL_DISTRO -- wslpath -u ($tmp -replace '\\','/') - wsl -d $env:WSL_DISTRO -- bash -l $wslTmp + run: node --no-warnings --experimental-strip-types scripts/github/wsl.ts install-root-and-render-coverage - name: Run scenario in WSL if: startsWith(inputs.scenario, 'wsl-') - shell: powershell env: NVIDIA_API_KEY: ${{ secrets.NVIDIA_API_KEY }} E2E_SUITE_FILTER: ${{ inputs.suite_filter }} SCENARIO: ${{ inputs.scenario }} - run: | - $env:WSLENV = "NVIDIA_API_KEY:E2E_SUITE_FILTER:NEMOCLAW_RECREATE_SANDBOX:SCENARIO:WSL_WORKDIR" - $script = @' - set -euo pipefail - cd "$WSL_WORKDIR" - export NVIDIA_API_KEY - export E2E_SUITE_FILTER - export NEMOCLAW_RECREATE_SANDBOX - bash test/e2e/runtime/run-scenario.sh "$SCENARIO" - '@ - $tmp = "$env:RUNNER_TEMP\wsl-step.sh" - [IO.File]::WriteAllText($tmp, ($script -replace "`r",""), (New-Object System.Text.UTF8Encoding $false)) - $wslTmp = wsl -d $env:WSL_DISTRO -- wslpath -u ($tmp -replace '\\','/') - wsl -d $env:WSL_DISTRO -- bash -l $wslTmp + run: node --no-warnings --experimental-strip-types scripts/github/wsl.ts run-scenario - name: Copy WSL artifacts back to checkout if: always() && startsWith(inputs.scenario, 'wsl-') - shell: powershell - run: | - $script = @" - set -euo pipefail - mkdir -p '$env:WSL_CHECKOUT_DIR/.e2e' '$env:WSL_CHECKOUT_DIR/test/e2e/logs' - if [ -d '$env:WSL_WORKDIR/.e2e' ]; then rsync -a '$env:WSL_WORKDIR/.e2e'/ '$env:WSL_CHECKOUT_DIR/.e2e'/; fi - if [ -d '$env:WSL_WORKDIR/test/e2e/logs' ]; then rsync -a '$env:WSL_WORKDIR/test/e2e/logs'/ '$env:WSL_CHECKOUT_DIR/test/e2e/logs'/; fi - "@ - $tmp = "$env:RUNNER_TEMP\wsl-step.sh" - [IO.File]::WriteAllText($tmp, ($script -replace "`r",""), (New-Object System.Text.UTF8Encoding $false)) - $wslTmp = wsl -d $env:WSL_DISTRO -- wslpath -u ($tmp -replace '\\','/') - wsl -d $env:WSL_DISTRO -- bash -l $wslTmp + run: node --no-warnings --experimental-strip-types scripts/github/wsl.ts copy-artifacts-to-checkout - name: Upload scenario artifacts if: always() diff --git a/.github/workflows/platform-vitest-main.yaml b/.github/workflows/platform-vitest-main.yaml index 2428382cb4..c06a2aac7d 100644 --- a/.github/workflows/platform-vitest-main.yaml +++ b/.github/workflows/platform-vitest-main.yaml @@ -73,145 +73,32 @@ jobs: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Setup Node.js on Windows host + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version: "22" + - name: Resolve workspace paths for WSL - shell: powershell - run: | - $winPath = "${{ github.workspace }}" - $drive = $winPath.Substring(0,1).ToLower() - $rest = $winPath.Substring(2).Replace('\','/') - $wslCheckoutPath = "/mnt/$drive$rest" - $wslWorkdir = "/tmp/nemoclaw-wsl-vitest/${env:GITHUB_RUN_ID}-${env:GITHUB_RUN_ATTEMPT}" - "WSL_CHECKOUT_DIR=$wslCheckoutPath" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - "WSL_WORKDIR=$wslWorkdir" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - Write-Host "WSL_CHECKOUT_DIR=$wslCheckoutPath" - Write-Host "WSL_WORKDIR=$wslWorkdir" + env: + WSL_WORKDIR_PREFIX: /tmp/nemoclaw-wsl-vitest + run: node --no-warnings --experimental-strip-types scripts/github/wsl.ts resolve-paths - name: Ensure Ubuntu WSL exists - shell: powershell - run: | - wsl --list --verbose 2>&1 | Out-Default - $null = wsl -d $env:WSL_DISTRO -- echo ok 2>&1 - if ($LASTEXITCODE -ne 0) { - $maxAttempts = 3 - $installed = $false - for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) { - Write-Host "Ubuntu not found - installing via wsl --install (attempt $attempt/$maxAttempts)" - wsl --install -d $env:WSL_DISTRO --no-launch --web-download - $installExitCode = $LASTEXITCODE - if ($installExitCode -eq 0) { - wsl -d $env:WSL_DISTRO -- bash -c 'echo distro initialised' - if ($LASTEXITCODE -eq 0) { - $installed = $true - break - } - } - - $null = wsl -d $env:WSL_DISTRO -- echo ok 2>&1 - if ($LASTEXITCODE -eq 0) { - $installed = $true - break - } - - if ($attempt -lt $maxAttempts) { - $null = wsl --unregister $env:WSL_DISTRO 2>&1 - Start-Sleep -Seconds ([Math]::Min(60, 20 * $attempt)) - } - } - - if (-not $installed) { - throw ("failed to install and initialize $env:WSL_DISTRO after $maxAttempts attempts") - } - } - wsl --set-default $env:WSL_DISTRO - if ($LASTEXITCODE -ne 0) { - throw ('wsl --set-default failed with exit code ' + $LASTEXITCODE) - } + run: node --no-warnings --experimental-strip-types scripts/github/wsl.ts ensure-ubuntu - name: Install Ubuntu dependencies - shell: powershell - run: | - $script = @' - set -euo pipefail - export DEBIAN_FRONTEND=noninteractive - printf '%s\n' \ - 'Acquire::ForceIPv4 "true";' \ - 'Acquire::Retries "5";' \ - >/etc/apt/apt.conf.d/99github-actions-network - apt-get update - apt-get install -y bash ca-certificates curl git jq lsb-release make python3 python3-pip rsync tar unzip xz-utils - '@ - $tmp = "$env:RUNNER_TEMP\wsl-step.sh" - [IO.File]::WriteAllText($tmp, ($script -replace "`r",""), (New-Object System.Text.UTF8Encoding $false)) - $wslTmp = wsl -d $env:WSL_DISTRO -- wslpath -u ($tmp -replace '\\','/') - wsl -d $env:WSL_DISTRO -- bash -l $wslTmp + run: node --no-warnings --experimental-strip-types scripts/github/wsl.ts install-ubuntu-deps - name: Install Node.js 22 in WSL - shell: powershell - run: | - $script = @' - set -euo pipefail - curl -fsSL https://deb.nodesource.com/setup_22.x | bash - - apt-get install -y nodejs - node --version - npm --version - '@ - $tmp = "$env:RUNNER_TEMP\wsl-step.sh" - [IO.File]::WriteAllText($tmp, ($script -replace "`r",""), (New-Object System.Text.UTF8Encoding $false)) - $wslTmp = wsl -d $env:WSL_DISTRO -- wslpath -u ($tmp -replace '\\','/') - wsl -d $env:WSL_DISTRO -- bash -l $wslTmp + run: node --no-warnings --experimental-strip-types scripts/github/wsl.ts install-node - name: Copy checkout into WSL ext4 workspace - shell: powershell - run: | - $checkout = $env:WSL_CHECKOUT_DIR - $workdir = $env:WSL_WORKDIR - $workdirParent = $workdir.Substring(0, $workdir.LastIndexOf('/')) - $script = @" - set -euo pipefail - rm -rf '$workdir' - mkdir -p '$workdirParent' - rsync -a --no-owner --no-group --delete \ - --exclude '/node_modules/' \ - --exclude '/nemoclaw/node_modules/' \ - --exclude '/nemoclaw-blueprint/.venv/' \ - '$checkout'/ '$workdir'/ - git config --global --add safe.directory '$workdir' - git -C '$workdir' reset --hard HEAD - git -C '$workdir' clean -ffdx - "@ - $tmp = "$env:RUNNER_TEMP\wsl-step.sh" - [IO.File]::WriteAllText($tmp, ($script -replace "`r",""), (New-Object System.Text.UTF8Encoding $false)) - $wslTmp = wsl -d $env:WSL_DISTRO -- wslpath -u ($tmp -replace '\\','/') - wsl -d $env:WSL_DISTRO -- bash -l $wslTmp + run: node --no-warnings --experimental-strip-types scripts/github/wsl.ts copy-checkout - name: Install dependencies and build in WSL - shell: powershell - run: | - $script = @" - set -euo pipefail - cd '$env:WSL_WORKDIR' - npm ci --ignore-scripts - npm run build:cli - cd nemoclaw - npm ci --ignore-scripts - npm run build - "@ - $tmp = "$env:RUNNER_TEMP\wsl-step.sh" - [IO.File]::WriteAllText($tmp, ($script -replace "`r",""), (New-Object System.Text.UTF8Encoding $false)) - $wslTmp = wsl -d $env:WSL_DISTRO -- wslpath -u ($tmp -replace '\\','/') - wsl -d $env:WSL_DISTRO -- bash -l $wslTmp + env: + WSL_NPM_INSTALL_MODE: ci + run: node --no-warnings --experimental-strip-types scripts/github/wsl.ts install-project - name: Run full Vitest suite in WSL - shell: powershell - run: | - $script = @" - set -euo pipefail - cd '$env:WSL_WORKDIR' - export NEMOCLAW_EXEC_TIMEOUT=60000 - export NEMOCLAW_TEST_TIMEOUT=60000 - npx vitest run --testTimeout 60000 - "@ - $tmp = "$env:RUNNER_TEMP\wsl-step.sh" - [IO.File]::WriteAllText($tmp, ($script -replace "`r",""), (New-Object System.Text.UTF8Encoding $false)) - $wslTmp = wsl -d $env:WSL_DISTRO -- wslpath -u ($tmp -replace '\\','/') - wsl -d $env:WSL_DISTRO -- bash -l $wslTmp + run: node --no-warnings --experimental-strip-types scripts/github/wsl.ts run-vitest diff --git a/.github/workflows/regression-e2e.yaml b/.github/workflows/regression-e2e.yaml index 29409fb1bd..3545c30c78 100644 --- a/.github/workflows/regression-e2e.yaml +++ b/.github/workflows/regression-e2e.yaml @@ -52,61 +52,26 @@ jobs: model_router_provider_routed_inference: ${{ steps.select.outputs.model_router_provider_routed_inference }} openclaw_plugin_runtime_exdev: ${{ steps.select.outputs.openclaw_plugin_runtime_exdev }} steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version: "22" + - id: select env: JOBS: ${{ inputs.jobs }} - run: | - set -euo pipefail - normalized="$(printf '%s' "$JOBS" | tr -d '[:space:]')" - - includes_job() { - case ",${normalized}," in - *",$1,"*) return 0 ;; - *) return 1 ;; - esac - } - - if [ -z "$normalized" ] || includes_job "dashboard-remote-bind-e2e"; then - echo "dashboard=true" >> "$GITHUB_OUTPUT" - else - echo "dashboard=false" >> "$GITHUB_OUTPUT" - fi - - if [ -z "$normalized" ] || includes_job "gateway-health-honest-e2e"; then - echo "gateway=true" >> "$GITHUB_OUTPUT" - else - echo "gateway=false" >> "$GITHUB_OUTPUT" - fi - - if [ -z "$normalized" ] || includes_job "gateway-drift-preflight-e2e"; then - echo "gateway_drift_preflight=true" >> "$GITHUB_OUTPUT" - else - echo "gateway_drift_preflight=false" >> "$GITHUB_OUTPUT" - fi - - if [ -z "$normalized" ] || includes_job "openshell-version-pin-e2e"; then - echo "openshell_version_pin=true" >> "$GITHUB_OUTPUT" - else - echo "openshell_version_pin=false" >> "$GITHUB_OUTPUT" - fi - - if [ -z "$normalized" ] || includes_job "onboard-inference-smoke-e2e"; then - echo "onboard_inference_smoke=true" >> "$GITHUB_OUTPUT" - else - echo "onboard_inference_smoke=false" >> "$GITHUB_OUTPUT" - fi - - if [ -z "$normalized" ] || includes_job "model-router-provider-routed-inference-e2e"; then - echo "model_router_provider_routed_inference=true" >> "$GITHUB_OUTPUT" - else - echo "model_router_provider_routed_inference=false" >> "$GITHUB_OUTPUT" - fi - - if [ -z "$normalized" ] || includes_job "openclaw-plugin-runtime-exdev-e2e"; then - echo "openclaw_plugin_runtime_exdev=true" >> "$GITHUB_OUTPUT" - else - echo "openclaw_plugin_runtime_exdev=false" >> "$GITHUB_OUTPUT" - fi + run: >- + node --no-warnings --experimental-strip-types scripts/github/select-jobs.ts + dashboard=dashboard-remote-bind-e2e + gateway=gateway-health-honest-e2e + gateway_drift_preflight=gateway-drift-preflight-e2e + openshell_version_pin=openshell-version-pin-e2e + onboard_inference_smoke=onboard-inference-smoke-e2e + model_router_provider_routed_inference=model-router-provider-routed-inference-e2e + openclaw_plugin_runtime_exdev=openclaw-plugin-runtime-exdev-e2e dashboard-remote-bind-e2e: needs: select_regression_jobs diff --git a/.github/workflows/wsl-e2e.yaml b/.github/workflows/wsl-e2e.yaml index ddbfed49ee..05fb78c0f6 100644 --- a/.github/workflows/wsl-e2e.yaml +++ b/.github/workflows/wsl-e2e.yaml @@ -43,208 +43,46 @@ jobs: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Setup Node.js on Windows host + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version: "22" + - name: Resolve workspace paths for WSL - shell: powershell - run: | - $winPath = "${{ github.workspace }}" - $drive = $winPath.Substring(0,1).ToLower() - $rest = $winPath.Substring(2).Replace('\','/') - $wslCheckoutPath = "/mnt/$drive$rest" - $wslWorkdir = "/tmp/nemoclaw-wsl-workdir/${env:GITHUB_RUN_ID}-${env:GITHUB_RUN_ATTEMPT}" - "WSL_CHECKOUT_DIR=$wslCheckoutPath" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - "WSL_WORKDIR=$wslWorkdir" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - Write-Host "WSL_CHECKOUT_DIR=$wslCheckoutPath" - Write-Host "WSL_WORKDIR=$wslWorkdir" + env: + WSL_WORKDIR_PREFIX: /tmp/nemoclaw-wsl-workdir + run: node --no-warnings --experimental-strip-types scripts/github/wsl.ts resolve-paths - name: Ensure Ubuntu WSL exists - shell: powershell - run: | - wsl --list --verbose 2>&1 | Out-Default - # Native commands do not throw in PowerShell; check LASTEXITCODE. - $null = wsl -d $env:WSL_DISTRO -- echo ok 2>&1 - if ($LASTEXITCODE -ne 0) { - $maxAttempts = 3 - $installed = $false - for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) { - Write-Host "Ubuntu not found - installing via wsl --install (attempt $attempt/$maxAttempts)" - wsl --install -d $env:WSL_DISTRO --no-launch --web-download - $installExitCode = $LASTEXITCODE - if ($installExitCode -eq 0) { - # The first launch initialises the distro with the default root user. - wsl -d $env:WSL_DISTRO -- bash -c 'echo distro initialised' - $launchExitCode = $LASTEXITCODE - if ($launchExitCode -eq 0) { - $installed = $true - break - } - Write-Warning "distro first-launch failed with exit code $launchExitCode" - } else { - Write-Warning "wsl --install failed with exit code $installExitCode" - } - - # Some WSL installs return a non-zero code after registering a usable distro. - $null = wsl -d $env:WSL_DISTRO -- echo ok 2>&1 - if ($LASTEXITCODE -eq 0) { - Write-Host 'Ubuntu became available after the install command returned non-zero' - $installed = $true - break - } - - if ($attempt -lt $maxAttempts) { - Write-Host 'Cleaning up any partial WSL registration before retrying' - $null = wsl --unregister $env:WSL_DISTRO 2>&1 - $delaySeconds = [Math]::Min(60, 20 * $attempt) - Write-Host "Retrying WSL install in $delaySeconds seconds..." - Start-Sleep -Seconds $delaySeconds - } - } - - if (-not $installed) { - throw ("failed to install and initialize $env:WSL_DISTRO after $maxAttempts attempts") - } - } else { - Write-Host 'Ubuntu already available' - } - wsl --set-default $env:WSL_DISTRO - if ($LASTEXITCODE -ne 0) { - throw ('wsl --set-default failed with exit code ' + $LASTEXITCODE) - } + run: node --no-warnings --experimental-strip-types scripts/github/wsl.ts ensure-ubuntu - name: Verify WSL - shell: powershell - run: | - wsl -d $env:WSL_DISTRO -- bash -lc "uname -a" - wsl -d $env:WSL_DISTRO -- bash -lc "cat /etc/os-release" + run: node --no-warnings --experimental-strip-types scripts/github/wsl.ts verify - name: Install Ubuntu dependencies - shell: powershell - run: | - $script = @' - set -euo pipefail - export DEBIAN_FRONTEND=noninteractive - printf '%s\n' \ - 'Acquire::ForceIPv4 "true";' \ - 'Acquire::Retries "5";' \ - >/etc/apt/apt.conf.d/99github-actions-network - apt-get update - apt-get install -y bash ca-certificates curl git jq lsb-release make python3 python3-pip rsync tar unzip xz-utils - '@ - $tmp = "$env:RUNNER_TEMP\wsl-step.sh" - [IO.File]::WriteAllText($tmp, ($script -replace "`r",""), (New-Object System.Text.UTF8Encoding $false)) - $wslTmp = wsl -d $env:WSL_DISTRO -- wslpath -u ($tmp -replace '\\','/') - wsl -d $env:WSL_DISTRO -- bash -l $wslTmp + run: node --no-warnings --experimental-strip-types scripts/github/wsl.ts install-ubuntu-deps - name: Install Node.js 22 in WSL - shell: powershell - run: | - $script = @' - set -euo pipefail - curl -fsSL https://deb.nodesource.com/setup_22.x | bash - - apt-get install -y nodejs - node --version - npm --version - '@ - $tmp = "$env:RUNNER_TEMP\wsl-step.sh" - [IO.File]::WriteAllText($tmp, ($script -replace "`r",""), (New-Object System.Text.UTF8Encoding $false)) - $wslTmp = wsl -d $env:WSL_DISTRO -- wslpath -u ($tmp -replace '\\','/') - wsl -d $env:WSL_DISTRO -- bash -l $wslTmp + run: node --no-warnings --experimental-strip-types scripts/github/wsl.ts install-node - name: Copy checkout into WSL ext4 workspace - shell: powershell - run: | - $checkout = $env:WSL_CHECKOUT_DIR - $workdir = $env:WSL_WORKDIR - $workdirParent = $workdir.Substring(0, $workdir.LastIndexOf('/')) - $script = @" - set -euo pipefail - echo 'Syncing checkout from $checkout to $workdir' - if [ ! -d '$checkout/.git' ]; then - echo 'Expected a Git checkout at $checkout' >&2 - exit 1 - fi - # Keep npm and test I/O on WSL's ext4 VHD. Running directly from - # /mnt/ (DrvFS) is slower and has Windows-style permission - # semantics that hide Linux permission regressions. - rm -rf '$workdir' - mkdir -p '$workdirParent' - rsync -a --no-owner --no-group --delete \ - --exclude '/node_modules/' \ - --exclude '/nemoclaw/node_modules/' \ - --exclude '/nemoclaw-blueprint/.venv/' \ - '$checkout'/ '$workdir'/ - git config --global --add safe.directory '$workdir' - git -C '$workdir' reset --hard HEAD - git -C '$workdir' clean -ffdx - git -C '$workdir' status --short - echo 'WSL ext4 workspace ready at $workdir' - "@ - $tmp = "$env:RUNNER_TEMP\wsl-step.sh" - [IO.File]::WriteAllText($tmp, ($script -replace "`r",""), (New-Object System.Text.UTF8Encoding $false)) - $wslTmp = wsl -d $env:WSL_DISTRO -- wslpath -u ($tmp -replace '\\','/') - wsl -d $env:WSL_DISTRO -- bash -l $wslTmp + run: node --no-warnings --experimental-strip-types scripts/github/wsl.ts copy-checkout - name: Install project dependencies and build plugin - shell: powershell - run: | - $script = @" - set -euo pipefail - cd '$env:WSL_WORKDIR' - npm install --ignore-scripts - npm run build:cli - cd nemoclaw - npm install --ignore-scripts - npm run build - "@ - $tmp = "$env:RUNNER_TEMP\wsl-step.sh" - [IO.File]::WriteAllText($tmp, ($script -replace "`r",""), (New-Object System.Text.UTF8Encoding $false)) - $wslTmp = wsl -d $env:WSL_DISTRO -- wslpath -u ($tmp -replace '\\','/') - wsl -d $env:WSL_DISTRO -- bash -l $wslTmp + env: + WSL_NPM_INSTALL_MODE: install + run: node --no-warnings --experimental-strip-types scripts/github/wsl.ts install-project - name: Detect Docker availability in WSL id: docker - shell: powershell - run: | - $script = @' - if docker info >/dev/null 2>&1; then - echo DOCKER_OK=1 - else - echo DOCKER_OK=0 - fi - '@ - $tmp = "$env:RUNNER_TEMP\wsl-step.sh" - [IO.File]::WriteAllText($tmp, ($script -replace "`r",""), (New-Object System.Text.UTF8Encoding $false)) - $wslTmp = wsl -d $env:WSL_DISTRO -- wslpath -u ($tmp -replace '\\','/') - $result = wsl -d $env:WSL_DISTRO -- bash -l $wslTmp - if ($result -match 'DOCKER_OK=1') { - 'docker_ok=true' | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append - Write-Host 'Docker is available in WSL' - } else { - 'docker_ok=false' | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append - Write-Host 'Docker is not available in WSL; full E2E will be skipped' - } + run: node --no-warnings --experimental-strip-types scripts/github/wsl.ts docker-available - name: Run WSL full E2E if: steps.docker.outputs.docker_ok == 'true' - shell: powershell env: NVIDIA_API_KEY: ${{ secrets.NVIDIA_API_KEY }} GITHUB_TOKEN: ${{ github.token }} - run: | - $script = @" - set -euo pipefail - cd '$env:WSL_WORKDIR' - export NVIDIA_API_KEY='$env:NVIDIA_API_KEY' - export GITHUB_TOKEN='$env:GITHUB_TOKEN' - export NEMOCLAW_NON_INTERACTIVE='$env:NEMOCLAW_NON_INTERACTIVE' - export NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE='$env:NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE' - export NEMOCLAW_RECREATE_SANDBOX='$env:NEMOCLAW_RECREATE_SANDBOX' - export NEMOCLAW_SANDBOX_NAME='$env:NEMOCLAW_SANDBOX_NAME' - bash test/e2e/test-full-e2e.sh - "@ - $tmp = "$env:RUNNER_TEMP\wsl-step.sh" - [IO.File]::WriteAllText($tmp, ($script -replace "`r",""), (New-Object System.Text.UTF8Encoding $false)) - $wslTmp = wsl -d $env:WSL_DISTRO -- wslpath -u ($tmp -replace '\\','/') - wsl -d $env:WSL_DISTRO -- bash -l $wslTmp + run: node --no-warnings --experimental-strip-types scripts/github/wsl.ts run-full-e2e - name: Explain skipped full E2E if: steps.docker.outputs.docker_ok != 'true' diff --git a/scripts/convert-docs-to-fern.mjs b/scripts/convert-docs-to-fern.mjs index 9d3aaa5eda..00ccea8869 100644 --- a/scripts/convert-docs-to-fern.mjs +++ b/scripts/convert-docs-to-fern.mjs @@ -46,7 +46,10 @@ function frontmatterFor(sourcePath, metadata, body) { const sidebarTitle = metadata.title?.nav ?? metadata["sidebar-title"]; const description = metadata.description?.main ?? metadata.description ?? ""; const descriptionAgent = - metadata.description?.agent ?? metadata["description-agent"] ?? metadata.description_agent ?? ""; + metadata.description?.agent ?? + metadata["description-agent"] ?? + metadata.description_agent ?? + ""; const keywords = metadata.keywords; const contentType = metadata.content?.type ?? ""; const skillPriority = metadata.skill?.priority ?? metadata.skill_priority ?? ""; diff --git a/scripts/github/changed-docs.ts b/scripts/github/changed-docs.ts new file mode 100644 index 0000000000..4199e6c685 --- /dev/null +++ b/scripts/github/changed-docs.ts @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** Emits changed Markdown/MDX files for docs-only PR checks. */ + +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { requireEnv, setOutput } from "./lib/actions.ts"; +import { runCapture } from "./lib/exec.ts"; +import { isMainModule } from "./lib/module.ts"; + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const DOC_DIFF_PATHS = [ + "*.md", + "*.mdx", + ":(exclude)node_modules/**", + ":(exclude)dist/**", + ":(exclude)vendor/**", + ":(exclude)build/**", +] as const; + +export function listChangedDocumentationFiles( + base: string, + head: string, + cwd = REPO_ROOT, +): string[] { + const result = runCapture( + "git", + ["diff", "--name-only", "--diff-filter=ACMR", base, head, "--", ...DOC_DIFF_PATHS], + cwd, + ); + if (result.status !== 0) { + throw new Error(`git diff failed:\n${result.stderr}`); + } + return [...new Set(result.stdout.split("\n").filter(Boolean))].sort(); +} + +function main(): void { + const files = listChangedDocumentationFiles(requireEnv("BASE_SHA"), requireEnv("HEAD_SHA")); + setOutput("has_files", files.length > 0); + if (files.length > 0) { + setOutput("files", files.join("\n")); + } +} + +if (isMainModule(import.meta.url)) { + main(); +} diff --git a/scripts/github/lib/actions.ts b/scripts/github/lib/actions.ts new file mode 100644 index 0000000000..a9d4d03ad5 --- /dev/null +++ b/scripts/github/lib/actions.ts @@ -0,0 +1,100 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** Shared helpers for small GitHub Actions TypeScript entrypoints. */ + +import { appendFileSync } from "node:fs"; + +export function requireEnv(name: string): string { + const value = process.env[name]; + if (value === undefined || value === "") { + throw new Error(`Missing required environment variable: ${name}`); + } + return value; +} + +export function optionalEnv(name: string, fallback = ""): string { + return process.env[name] ?? fallback; +} + +export function parseCsv(value: string): Set { + return new Set( + value + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean), + ); +} + +export function selectCsvJobs( + requestedJobs: string, + outputsToJobIds: ReadonlyMap, +): Map { + const requested = parseCsv(requestedJobs); + const selectAll = requested.size === 0; + return new Map( + [...outputsToJobIds].map(([outputName, jobId]) => [ + outputName, + selectAll || requested.has(jobId), + ]), + ); +} + +export function appendLineFromEnv(envName: string, line: string): void { + const filePath = process.env[envName]; + if (!filePath) { + console.log(`${envName} not set; ${line}`); + return; + } + appendFileSync(filePath, `${line}\n`, { encoding: "utf-8" }); +} + +export function setOutput(name: string, value: string | boolean | number): void { + const rendered = String(value); + const outputFile = process.env.GITHUB_OUTPUT; + if (!outputFile) { + console.log(`${name}=${rendered}`); + return; + } + if (rendered.includes("\n")) { + const delimiter = `EOF_${name}_${Date.now()}_${Math.random().toString(16).slice(2)}`; + appendFileSync(outputFile, `${name}<<${delimiter}\n${rendered}\n${delimiter}\n`, { + encoding: "utf-8", + }); + return; + } + appendFileSync(outputFile, `${name}=${rendered}\n`, { encoding: "utf-8" }); +} + +function uniqueDelimiter(name: string, value: string): string { + let delimiter = `EOF_${name}_${Date.now()}_${Math.random().toString(16).slice(2)}`; + while (value.includes(delimiter)) { + delimiter = `EOF_${name}_${Date.now()}_${Math.random().toString(16).slice(2)}`; + } + return delimiter; +} + +export function exportEnv(name: string, value: string): void { + if (!value.includes("\n")) { + appendLineFromEnv("GITHUB_ENV", `${name}=${value}`); + return; + } + + const delimiter = uniqueDelimiter(name, value); + appendLineFromEnv("GITHUB_ENV", `${name}<<${delimiter}`); + appendLineFromEnv("GITHUB_ENV", value); + appendLineFromEnv("GITHUB_ENV", delimiter); +} + +export function appendStepSummary(markdown: string): void { + const summaryFile = process.env.GITHUB_STEP_SUMMARY; + if (!summaryFile) { + console.log(markdown); + return; + } + appendFileSync(summaryFile, markdown, { encoding: "utf-8" }); +} + +export function shellQuote(value: string): string { + return `'${value.replaceAll("'", `'"'"'`)}'`; +} diff --git a/scripts/github/lib/exec.ts b/scripts/github/lib/exec.ts new file mode 100644 index 0000000000..12324eff59 --- /dev/null +++ b/scripts/github/lib/exec.ts @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** Child-process helpers for GitHub Actions utility scripts. */ + +import { spawnSync } from "node:child_process"; + +export type CommandResult = { + status: number; + stdout: string; + stderr: string; +}; + +export function runCapture(command: string, args: readonly string[], cwd?: string): CommandResult { + const result = spawnSync(command, args, { + cwd, + encoding: "utf-8", + maxBuffer: 20 * 1024 * 1024, + }); + if (result.error) { + throw result.error; + } + return { + status: result.status ?? 1, + stdout: result.stdout ?? "", + stderr: result.stderr ?? "", + }; +} + +export function runChecked(command: string, args: readonly string[], cwd?: string): void { + const result = spawnSync(command, args, { + cwd, + encoding: "utf-8", + stdio: ["inherit", "inherit", "pipe"], + }); + if (result.error) { + throw result.error; + } + if ((result.status ?? 1) !== 0) { + const details = [ + `${command} ${args.join(" ")} failed`, + `exit code: ${result.status ?? 1}`, + ...(result.signal ? [`signal: ${result.signal}`] : []), + ...(cwd ? [`cwd: ${cwd}`] : []), + ...(result.stderr ? [`stderr:\n${result.stderr}`] : []), + ]; + throw new Error(details.join("\n")); + } +} diff --git a/scripts/github/lib/module.ts b/scripts/github/lib/module.ts new file mode 100644 index 0000000000..8c4c9014e4 --- /dev/null +++ b/scripts/github/lib/module.ts @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** ESM entrypoint detection that also works for .ts files run by node --experimental-strip-types. */ + +import { pathToFileURL } from "node:url"; + +export function isMainModule(importMetaUrl: string): boolean { + const argvPath = process.argv[1]; + return argvPath !== undefined && pathToFileURL(argvPath).href === importMetaUrl; +} diff --git a/scripts/github/lib/shellcheck-sarif.ts b/scripts/github/lib/shellcheck-sarif.ts new file mode 100644 index 0000000000..94175d68ad --- /dev/null +++ b/scripts/github/lib/shellcheck-sarif.ts @@ -0,0 +1,122 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** Converts ShellCheck json1 output into the SARIF subset accepted by CodeQL upload. */ + +export type ShellCheckComment = { + file: string; + line: number; + endLine?: number; + column: number; + endColumn?: number; + level: string; + code: number; + message: string; +}; + +export type ShellCheckJson = { + comments?: ShellCheckComment[]; +}; + +type SarifLevel = "error" | "warning" | "note"; + +type Sarif = { + version: "2.1.0"; + $schema: string; + runs: Array<{ + tool: { + driver: { + name: string; + informationUri: string; + rules: Array<{ + id: string; + name: string; + shortDescription: { text: string }; + }>; + }; + }; + results: Array<{ + ruleId: string; + level: SarifLevel; + message: { text: string }; + locations: Array<{ + physicalLocation: { + artifactLocation: { uri: string }; + region: { + startLine: number; + startColumn: number; + endLine?: number; + endColumn?: number; + }; + }; + }>; + }>; + }>; +}; + +function mapLevel(level: string): SarifLevel { + if (level === "error") { + return "error"; + } + if (level === "warning") { + return "warning"; + } + return "note"; +} + +function ruleId(code: number): string { + return `SC${code}`; +} + +export function shellcheckJsonToSarif(shellcheck: ShellCheckJson): Sarif { + const comments = shellcheck.comments ?? []; + const rules = [...new Map(comments.map((comment) => [comment.code, comment])).values()] + .sort((left, right) => left.code - right.code) + .map((comment) => ({ + id: ruleId(comment.code), + name: ruleId(comment.code), + shortDescription: { text: comment.level }, + })); + + return { + version: "2.1.0", + $schema: "https://json.schemastore.org/sarif-2.1.0.json", + runs: [ + { + tool: { + driver: { + name: "ShellCheck", + informationUri: "https://www.shellcheck.net/", + rules, + }, + }, + results: comments.map((comment) => ({ + ruleId: ruleId(comment.code), + level: mapLevel(comment.level), + message: { text: comment.message }, + locations: [ + { + physicalLocation: { + artifactLocation: { uri: comment.file }, + region: { + startLine: comment.line, + startColumn: comment.column, + ...(comment.endLine === undefined ? {} : { endLine: comment.endLine }), + ...(comment.endColumn === undefined ? {} : { endColumn: comment.endColumn }), + }, + }, + }, + ], + })), + }, + ], + }; +} + +export function emptySarif(): Sarif { + return { + version: "2.1.0", + $schema: "https://json.schemastore.org/sarif-2.1.0.json", + runs: [], + }; +} diff --git a/scripts/github/select-jobs.ts b/scripts/github/select-jobs.ts new file mode 100644 index 0000000000..c93bd6c4d7 --- /dev/null +++ b/scripts/github/select-jobs.ts @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** Shared CSV job selector for manually dispatched workflow fan-out gates. */ + +import { optionalEnv, selectCsvJobs, setOutput } from "./lib/actions.ts"; +import { isMainModule } from "./lib/module.ts"; + +export function parseOutputMappings(args: readonly string[]): Map { + const mappings = new Map(); + for (const arg of args) { + const separator = arg.indexOf("="); + if (separator <= 0 || separator === arg.length - 1) { + throw new Error(`Expected mapping in output_name=job-id form, got: ${arg}`); + } + mappings.set(arg.slice(0, separator), arg.slice(separator + 1)); + } + if (mappings.size === 0) { + throw new Error("At least one output_name=job-id mapping is required."); + } + return mappings; +} + +function main(): void { + const selections = selectCsvJobs(optionalEnv("JOBS"), parseOutputMappings(process.argv.slice(2))); + for (const [outputName, selected] of selections) { + setOutput(outputName, selected); + } +} + +if (isMainModule(import.meta.url)) { + main(); +} diff --git a/scripts/github/shellcheck-sarif.ts b/scripts/github/shellcheck-sarif.ts new file mode 100644 index 0000000000..3174607731 --- /dev/null +++ b/scripts/github/shellcheck-sarif.ts @@ -0,0 +1,84 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** Finds shell scripts, runs ShellCheck, and writes Code Scanning SARIF. */ + +import { writeFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { setOutput } from "./lib/actions.ts"; +import { runCapture } from "./lib/exec.ts"; +import { emptySarif, shellcheckJsonToSarif, type ShellCheckJson } from "./lib/shellcheck-sarif.ts"; +import { isMainModule } from "./lib/module.ts"; + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const SHELLCHECK_JSON_PATH = "shellcheck.json"; +const SHELLCHECK_SARIF_PATH = "shellcheck.sarif"; +const SHELL_FILES_PATH = "shell-files.txt"; + +function listShellFiles(): string[] { + const result = runCapture("git", ["ls-files", "*.sh", "install.sh", "uninstall.sh"], REPO_ROOT); + if (result.status !== 0) { + throw new Error(`git ls-files failed:\n${result.stderr}`); + } + return result.stdout + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .sort(); +} + +function parseShellcheckJson(stdout: string): ShellCheckJson | undefined { + try { + return JSON.parse(stdout) as ShellCheckJson; + } catch { + return undefined; + } +} + +function main(): void { + const files = listShellFiles(); + writeFileSync(path.join(REPO_ROOT, SHELL_FILES_PATH), `${files.join("\n")}\n`, "utf-8"); + setOutput("has_files", files.length > 0); + + if (files.length === 0) { + writeFileSync( + path.join(REPO_ROOT, SHELLCHECK_SARIF_PATH), + `${JSON.stringify(emptySarif(), null, 2)}\n`, + ); + setOutput("has_runs", false); + return; + } + + const shellcheck = runCapture("shellcheck", ["--format=json1", ...files], REPO_ROOT); + writeFileSync(path.join(REPO_ROOT, SHELLCHECK_JSON_PATH), shellcheck.stdout, "utf-8"); + + const parsed = parseShellcheckJson(shellcheck.stdout); + if (parsed) { + const sarif = shellcheckJsonToSarif(parsed); + writeFileSync( + path.join(REPO_ROOT, SHELLCHECK_SARIF_PATH), + `${JSON.stringify(sarif, null, 2)}\n`, + ); + setOutput("has_runs", sarif.runs.length > 0); + } else { + console.log( + `ShellCheck produced invalid JSON (exit=${shellcheck.status}); writing empty SARIF fallback.`, + ); + writeFileSync( + path.join(REPO_ROOT, SHELLCHECK_SARIF_PATH), + `${JSON.stringify(emptySarif(), null, 2)}\n`, + ); + setOutput("has_runs", false); + } + + if (shellcheck.status !== 0) { + console.log( + `ShellCheck reported findings (exit=${shellcheck.status}); continuing so SARIF can be uploaded.`, + ); + } +} + +if (isMainModule(import.meta.url)) { + main(); +} diff --git a/scripts/github/wsl.ts b/scripts/github/wsl.ts new file mode 100644 index 0000000000..a224e0a836 --- /dev/null +++ b/scripts/github/wsl.ts @@ -0,0 +1,389 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** Reusable WSL orchestration for Windows-hosted GitHub Actions jobs. */ + +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { exportEnv, optionalEnv, requireEnv, setOutput, shellQuote } from "./lib/actions.ts"; +import { runCapture, runChecked } from "./lib/exec.ts"; +import { isMainModule } from "./lib/module.ts"; + +export function windowsPathToWsl(winPath: string): string { + const driveMatch = winPath.match(/^([A-Za-z]):[\\/](.*)$/); + if (!driveMatch) { + throw new Error(`Expected an absolute Windows path with a drive letter, got: ${winPath}`); + } + const drive = driveMatch[1].toLowerCase(); + const rest = driveMatch[2].replaceAll("\\", "/"); + return `/mnt/${drive}/${rest}`; +} + +function distro(): string { + return optionalEnv("WSL_DISTRO", "Ubuntu"); +} + +function wsl(args: readonly string[]): ReturnType { + return runCapture("wsl", args); +} + +function wslChecked(args: readonly string[]): void { + runChecked("wsl", args); +} + +function assertWindowsHost(): void { + if (process.platform !== "win32") { + throw new Error("scripts/github/wsl.ts must be run on a Windows GitHub Actions runner."); + } +} + +function sleep(milliseconds: number): void { + const end = Date.now() + milliseconds; + while (Date.now() < end) { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, Math.min(1000, end - Date.now())); + } +} + +function writeBashScript(script: string): string { + const tempRoot = optionalEnv("RUNNER_TEMP", os.tmpdir()); + const dir = mkdtempSync(path.join(tempRoot, "nemoclaw-wsl-step-")); + const scriptPath = path.join(dir, "step.sh"); + writeFileSync(scriptPath, script.replaceAll("\r\n", "\n"), { encoding: "utf-8" }); + return scriptPath; +} + +function windowsPathToWslViaDistro(winPath: string): string { + const normalized = winPath.replaceAll("\\", "/"); + const result = wsl(["-d", distro(), "--", "wslpath", "-u", normalized]); + if (result.status !== 0) { + throw new Error(`wslpath failed for ${winPath}:\n${result.stderr}`); + } + return result.stdout.trim(); +} + +export function withWslEnv(names: readonly string[], callback: () => T): T { + if (names.length === 0) { + return callback(); + } + + const previous = process.env.WSLENV; + const existing = new Set((previous ?? "").split(":").filter(Boolean)); + for (const name of names) { + existing.add(name); + } + process.env.WSLENV = [...existing].join(":"); + try { + return callback(); + } finally { + if (previous === undefined) { + delete process.env.WSLENV; + } else { + process.env.WSLENV = previous; + } + } +} + +function runBashInWsl(script: string, passEnv: readonly string[] = []): void { + const scriptPath = writeBashScript(script); + try { + const wslScriptPath = windowsPathToWslViaDistro(scriptPath); + withWslEnv(passEnv, () => wslChecked(["-d", distro(), "--", "bash", "-l", wslScriptPath])); + } finally { + rmSync(path.dirname(scriptPath), { force: true, recursive: true }); + } +} + +function resolvePaths(): void { + assertWindowsHost(); + const workspace = requireEnv("GITHUB_WORKSPACE"); + const prefix = optionalEnv("WSL_WORKDIR_PREFIX", "/tmp/nemoclaw-wsl-workdir"); + const runId = requireEnv("GITHUB_RUN_ID"); + const attempt = optionalEnv("GITHUB_RUN_ATTEMPT", "1"); + const checkoutDir = windowsPathToWsl(workspace); + const workdir = `${prefix}/${runId}-${attempt}`; + exportEnv("WSL_CHECKOUT_DIR", checkoutDir); + exportEnv("WSL_WORKDIR", workdir); + console.log(`WSL_CHECKOUT_DIR=${checkoutDir}`); + console.log(`WSL_WORKDIR=${workdir}`); +} + +function distroAvailable(): boolean { + return wsl(["-d", distro(), "--", "echo", "ok"]).status === 0; +} + +function normalizeWslMessage(message: string): string { + return message.replaceAll("\u0000", "").toLowerCase(); +} + +function listDistributions(): void { + const result = wsl(["--list", "--verbose"]); + process.stdout.write(result.stdout); + process.stderr.write(result.stderr); + if (result.status === 0) { + return; + } + + const message = normalizeWslMessage(`${result.stdout}\n${result.stderr}`); + if (message.includes("no installed distributions")) { + console.log("No WSL distributions are installed yet; continuing with Ubuntu installation."); + return; + } + + throw new Error(`wsl --list --verbose failed with exit code ${result.status}`); +} + +function ensureUbuntu(): void { + assertWindowsHost(); + listDistributions(); + if (!distroAvailable()) { + const maxAttempts = 3; + let installed = false; + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + console.log( + `Ubuntu not found - installing via wsl --install (attempt ${attempt}/${maxAttempts})`, + ); + const install = wsl(["--install", "-d", distro(), "--no-launch", "--web-download"]); + if (install.status === 0) { + const launch = wsl(["-d", distro(), "--", "bash", "-c", "echo distro initialised"]); + if (launch.status === 0) { + installed = true; + break; + } + console.warn(`distro first-launch failed with exit code ${launch.status}`); + } else { + console.warn(`wsl --install failed with exit code ${install.status}`); + } + + if (distroAvailable()) { + console.log("Ubuntu became available after the install command returned non-zero"); + installed = true; + break; + } + + if (attempt < maxAttempts) { + console.log("Cleaning up any partial WSL registration before retrying"); + wsl(["--unregister", distro()]); + const delaySeconds = Math.min(60, 20 * attempt); + console.log(`Retrying WSL install in ${delaySeconds} seconds...`); + sleep(delaySeconds * 1000); + } + } + if (!installed) { + throw new Error(`failed to install and initialize ${distro()} after ${maxAttempts} attempts`); + } + } else { + console.log("Ubuntu already available"); + } + wslChecked(["--set-default", distro()]); +} + +function verify(): void { + wslChecked(["-d", distro(), "--", "bash", "-lc", "uname -a"]); + wslChecked(["-d", distro(), "--", "bash", "-lc", "cat /etc/os-release"]); +} + +function installUbuntuDeps(): void { + const installDocker = optionalEnv("WSL_INSTALL_DOCKER") === "1"; + runBashInWsl(` +set -euo pipefail +export DEBIAN_FRONTEND=noninteractive +printf '%s\n' \ + 'Acquire::ForceIPv4 "true";' \ + 'Acquire::Retries "5";' \ + >/etc/apt/apt.conf.d/99github-actions-network +apt-get update +apt-get install -y bash ca-certificates curl git jq lsb-release make python3 python3-pip rsync tar unzip xz-utils +${ + installDocker + ? `if ! docker info >/dev/null 2>&1; then + apt-get install -y docker.io + service docker start || /etc/init.d/docker start || true + timeout 30 bash -c 'until docker info >/dev/null 2>&1; do sleep 2; done' +fi +docker --version +docker info >/dev/null` + : "" +} +`); +} + +function installNode(): void { + runBashInWsl(` +set -euo pipefail +curl -fsSL https://deb.nodesource.com/setup_22.x | bash - +apt-get install -y nodejs +node --version +npm --version +`); +} + +function copyCheckout(): void { + const checkout = requireEnv("WSL_CHECKOUT_DIR"); + const workdir = requireEnv("WSL_WORKDIR"); + const parent = workdir.slice(0, workdir.lastIndexOf("/")); + runBashInWsl(` +set -euo pipefail +echo 'Syncing checkout from ${checkout} to ${workdir}' +if [ ! -d ${shellQuote(`${checkout}/.git`)} ]; then + echo 'Expected a Git checkout at ${checkout}' >&2 + exit 1 +fi +rm -rf ${shellQuote(workdir)} +mkdir -p ${shellQuote(parent)} +rsync -a --no-owner --no-group --delete \ + --exclude '/node_modules/' \ + --exclude '/nemoclaw/node_modules/' \ + --exclude '/nemoclaw-blueprint/.venv/' \ + ${shellQuote(`${checkout}/`)} ${shellQuote(`${workdir}/`)} +git config --global --add safe.directory ${shellQuote(workdir)} +git -C ${shellQuote(workdir)} reset --hard HEAD +git -C ${shellQuote(workdir)} clean -ffdx +git -C ${shellQuote(workdir)} status --short +echo 'WSL ext4 workspace ready at ${workdir}' +`); +} + +export function npmInstallCommandForMode(mode: string): string { + if (mode === "install") { + return "npm install --ignore-scripts"; + } + if (mode === "ci") { + return "npm ci --ignore-scripts"; + } + throw new Error(`Unsupported WSL_NPM_INSTALL_MODE: ${mode}`); +} + +function installProject(): void { + const workdir = requireEnv("WSL_WORKDIR"); + const npmInstallCommand = npmInstallCommandForMode(optionalEnv("WSL_NPM_INSTALL_MODE", "ci")); + runBashInWsl(` +set -euo pipefail +cd ${shellQuote(workdir)} +${npmInstallCommand} +npm run build:cli +cd nemoclaw +${npmInstallCommand} +npm run build +`); +} + +function installRootAndRenderCoverage(): void { + const workdir = requireEnv("WSL_WORKDIR"); + runBashInWsl(` +set -euo pipefail +cd ${shellQuote(workdir)} +npm ci --ignore-scripts +mkdir -p .e2e +bash test/e2e/runtime/coverage-report.sh > .e2e/coverage.md +`); +} + +function dockerAvailable(): void { + const result = wsl(["-d", distro(), "--", "bash", "-lc", "docker info >/dev/null 2>&1"]); + const available = result.status === 0; + setOutput("docker_ok", available); + console.log(available ? "Docker is available in WSL" : "Docker is not available in WSL"); +} + +export function buildFullE2EScript(workdir: string): string { + return ` +set -euo pipefail +cd ${shellQuote(workdir)} +export NVIDIA_API_KEY="\${NVIDIA_API_KEY:-}" +export GITHUB_TOKEN="\${GITHUB_TOKEN:-}" +export NEMOCLAW_NON_INTERACTIVE="\${NEMOCLAW_NON_INTERACTIVE:-1}" +export NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE="\${NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE:-1}" +export NEMOCLAW_RECREATE_SANDBOX="\${NEMOCLAW_RECREATE_SANDBOX:-1}" +export NEMOCLAW_SANDBOX_NAME="\${NEMOCLAW_SANDBOX_NAME:-e2e-wsl}" +bash test/e2e/test-full-e2e.sh +`; +} + +function runFullE2E(): void { + const workdir = requireEnv("WSL_WORKDIR"); + runBashInWsl(buildFullE2EScript(workdir), [ + "NVIDIA_API_KEY", + "GITHUB_TOKEN", + "NEMOCLAW_NON_INTERACTIVE", + "NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE", + "NEMOCLAW_RECREATE_SANDBOX", + "NEMOCLAW_SANDBOX_NAME", + ]); +} + +function runVitest(): void { + const workdir = requireEnv("WSL_WORKDIR"); + runBashInWsl(` +set -euo pipefail +cd ${shellQuote(workdir)} +export NEMOCLAW_EXEC_TIMEOUT=60000 +export NEMOCLAW_TEST_TIMEOUT=60000 +npx vitest run --testTimeout 60000 +`); +} + +export function buildRunScenarioScript(workdir: string, scenario: string): string { + return ` +set -euo pipefail +cd ${shellQuote(workdir)} +export NVIDIA_API_KEY="\${NVIDIA_API_KEY:-}" +export E2E_SUITE_FILTER="\${E2E_SUITE_FILTER:-}" +export NEMOCLAW_RECREATE_SANDBOX="\${NEMOCLAW_RECREATE_SANDBOX:-1}" +bash test/e2e/runtime/run-scenario.sh ${shellQuote(scenario)} +`; +} + +function runScenario(): void { + const workdir = requireEnv("WSL_WORKDIR"); + const scenario = requireEnv("SCENARIO"); + runBashInWsl(buildRunScenarioScript(workdir, scenario), [ + "NVIDIA_API_KEY", + "E2E_SUITE_FILTER", + "NEMOCLAW_RECREATE_SANDBOX", + ]); +} + +function copyArtifactsToCheckout(): void { + const checkout = requireEnv("WSL_CHECKOUT_DIR"); + const workdir = requireEnv("WSL_WORKDIR"); + runBashInWsl(` +set -euo pipefail +mkdir -p ${shellQuote(`${checkout}/.e2e`)} ${shellQuote(`${checkout}/test/e2e/logs`)} +if [ -d ${shellQuote(`${workdir}/.e2e`)} ]; then + rsync -a ${shellQuote(`${workdir}/.e2e/`)} ${shellQuote(`${checkout}/.e2e/`)} +fi +if [ -d ${shellQuote(`${workdir}/test/e2e/logs`)} ]; then + rsync -a ${shellQuote(`${workdir}/test/e2e/logs/`)} ${shellQuote(`${checkout}/test/e2e/logs/`)} +fi +`); +} + +const COMMANDS = new Map void>([ + ["resolve-paths", resolvePaths], + ["ensure-ubuntu", ensureUbuntu], + ["verify", verify], + ["install-ubuntu-deps", installUbuntuDeps], + ["install-node", installNode], + ["copy-checkout", copyCheckout], + ["install-project", installProject], + ["install-root-and-render-coverage", installRootAndRenderCoverage], + ["docker-available", dockerAvailable], + ["run-full-e2e", runFullE2E], + ["run-vitest", runVitest], + ["run-scenario", runScenario], + ["copy-artifacts-to-checkout", copyArtifactsToCheckout], +]); + +function main(): void { + const command = process.argv[2]; + const handler = command === undefined ? undefined : COMMANDS.get(command); + if (!handler) { + throw new Error(`Unknown WSL helper command: ${command ?? ""}`); + } + handler(); +} + +if (isMainModule(import.meta.url)) { + main(); +} diff --git a/test/github-workflow-utils.test.ts b/test/github-workflow-utils.test.ts new file mode 100644 index 0000000000..091c1965b2 --- /dev/null +++ b/test/github-workflow-utils.test.ts @@ -0,0 +1,172 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { spawnSync } from "node:child_process"; +import { exportEnv, shellQuote, selectCsvJobs } from "../scripts/github/lib/actions.ts"; +import { runChecked } from "../scripts/github/lib/exec.ts"; +import { shellcheckJsonToSarif } from "../scripts/github/lib/shellcheck-sarif.ts"; +import { parseOutputMappings } from "../scripts/github/select-jobs.ts"; +import { + buildFullE2EScript, + buildRunScenarioScript, + npmInstallCommandForMode, + windowsPathToWsl, + withWslEnv, +} from "../scripts/github/wsl.ts"; + +const ORIGINAL_WSLENV = process.env.WSLENV; +let envFileToRemove: string | undefined; + +afterEach(() => { + delete process.env.GITHUB_ENV; + delete process.env.NVIDIA_API_KEY; + delete process.env.GITHUB_TOKEN; + if (ORIGINAL_WSLENV === undefined) { + delete process.env.WSLENV; + } else { + process.env.WSLENV = ORIGINAL_WSLENV; + } + if (envFileToRemove) { + rmSync(path.dirname(envFileToRemove), { force: true, recursive: true }); + envFileToRemove = undefined; + } +}); + +describe("GitHub workflow utility helpers", () => { + it("converts Windows checkout paths to WSL mount paths", () => { + expect(windowsPathToWsl("D:\\a\\NemoClaw\\NemoClaw")).toBe("/mnt/d/a/NemoClaw/NemoClaw"); + expect(windowsPathToWsl("C:/Users/runneradmin/work/repo")).toBe( + "/mnt/c/Users/runneradmin/work/repo", + ); + }); + + it("quotes single quotes for generated bash snippets", () => { + expect(shellQuote("plain")).toBe("'plain'"); + expect(shellQuote("can't leak")).toBe("'can'\"'\"'t leak'"); + }); + + it("selects all jobs for empty dispatch input and exact matches otherwise", () => { + const mapping = parseOutputMappings([ + "dashboard=dashboard-remote-bind-e2e", + "gateway=gateway-health-honest-e2e", + ]); + expect(Object.fromEntries(selectCsvJobs("", mapping))).toEqual({ + dashboard: true, + gateway: true, + }); + expect(Object.fromEntries(selectCsvJobs(" gateway-health-honest-e2e ", mapping))).toEqual({ + dashboard: false, + gateway: true, + }); + }); + + it("converts shellcheck json1 comments to SARIF", () => { + const sarif = shellcheckJsonToSarif({ + comments: [ + { + file: "scripts/example.sh", + line: 3, + endLine: 3, + column: 7, + endColumn: 12, + level: "warning", + code: 2086, + message: "Double quote to prevent globbing and word splitting.", + }, + ], + }); + + expect(sarif.runs).toHaveLength(1); + expect(sarif.runs[0].tool.driver.rules[0].id).toBe("SC2086"); + expect(sarif.runs[0].results[0]).toMatchObject({ + ruleId: "SC2086", + level: "warning", + message: { text: "Double quote to prevent globbing and word splitting." }, + }); + }); + + it("writes multiline GitHub environment values using delimiter syntax", () => { + const dir = mkdtempSync(path.join(os.tmpdir(), "nemoclaw-gh-env-")); + envFileToRemove = path.join(dir, "env"); + process.env.GITHUB_ENV = envFileToRemove; + + exportEnv("MULTILINE", "one\ntwo"); + const written = readFileSync(envFileToRemove, "utf-8"); + + expect(written).toMatch(/^MULTILINE< { + expect(npmInstallCommandForMode("ci")).toBe("npm ci --ignore-scripts"); + expect(npmInstallCommandForMode("install")).toBe("npm install --ignore-scripts"); + expect(() => npmInstallCommandForMode("npm install && curl example.test")).toThrow( + /Unsupported WSL_NPM_INSTALL_MODE/, + ); + }); + + it("runs the select-jobs entrypoint and writes GitHub outputs", () => { + const dir = mkdtempSync(path.join(os.tmpdir(), "nemoclaw-gh-output-")); + envFileToRemove = path.join(dir, "output"); + const result = spawnSync( + process.execPath, + [ + "--no-warnings", + "--experimental-strip-types", + "scripts/github/select-jobs.ts", + "dashboard=dashboard-remote-bind-e2e", + "gateway=gateway-health-honest-e2e", + ], + { + cwd: process.cwd(), + encoding: "utf-8", + env: { + ...process.env, + GITHUB_OUTPUT: envFileToRemove, + JOBS: "gateway-health-honest-e2e", + }, + }, + ); + + expect(result.status, result.stderr).toBe(0); + expect(readFileSync(envFileToRemove, "utf-8")).toBe("dashboard=false\ngateway=true\n"); + }); + + it("keeps secret values out of generated WSL scripts", () => { + process.env.NVIDIA_API_KEY = "nvapi-secret-test-value"; + process.env.GITHUB_TOKEN = "ghs-secret-test-value"; + + const fullE2E = buildFullE2EScript("/tmp/workdir"); + const scenario = buildRunScenarioScript("/tmp/workdir", "wsl-repo-cloud-openclaw"); + + expect(fullE2E).not.toContain("nvapi-secret-test-value"); + expect(fullE2E).not.toContain("ghs-secret-test-value"); + expect(fullE2E).toContain("${NVIDIA_API_KEY:-}"); + expect(scenario).not.toContain("nvapi-secret-test-value"); + expect(scenario).toContain("${NVIDIA_API_KEY:-}"); + }); + + it("adds WSLENV entries only for the duration of a WSL invocation", () => { + process.env.WSLENV = "PATH/p"; + let observed = ""; + + const returned = withWslEnv(["NVIDIA_API_KEY", "GITHUB_TOKEN"], () => { + observed = process.env.WSLENV ?? ""; + return 42; + }); + + expect(returned).toBe(42); + expect(observed.split(":")).toEqual(["PATH/p", "NVIDIA_API_KEY", "GITHUB_TOKEN"]); + expect(process.env.WSLENV).toBe("PATH/p"); + }); + + it("includes stderr in checked command failures", () => { + expect(() => + runChecked(process.execPath, ["-e", "console.error('boom'); process.exit(7)"]), + ).toThrow(/exit code: 7[\s\S]*stderr:\nboom/); + }); +});