diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index 4f55bdb6434..f2ed802e5e0 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -5849,6 +5849,7 @@ jobs: JOB_PR_NUMBER: ${{ inputs.pr_number }} JOB_TARGETS: ${{ inputs.targets }} JOBS: ${{ inputs.jobs }} + NEEDS_JSON: ${{ toJSON(needs) }} with: script: | const path = require('node:path'); @@ -5856,7 +5857,7 @@ jobs: const { resolveReportPr, loadReportJobs, renderE2eReport } = await import( pathToFileURL(path.join(process.env.GITHUB_WORKSPACE, 'tools/e2e/report-e2e-results.mts')).href ); - const needs = ${{ toJSON(needs) }}; + const needs = JSON.parse(process.env.NEEDS_JSON || '{}'); const prNumber = await resolveReportPr({ github, context, core, env: process.env }); if (prNumber === undefined) return; const { apiJobs, loaded } = await loadReportJobs({ github, context, core }); @@ -5917,6 +5918,7 @@ jobs: RUNTIME_ARTIFACTS: ${{ runner.temp }}/e2e-runtime-audit RUNTIME_SUMMARY_FILE: ${{ runner.temp }}/e2e-runtime-summary.json TARGETS: ${{ inputs.targets }} + NEEDS_JSON: ${{ toJSON(needs) }} with: script: | const path = require('path'); @@ -5935,7 +5937,7 @@ jobs: const runtimeHistory = require( path.join(process.env.GITHUB_WORKSPACE, 'scripts/scorecard/analyze-runtime-history.mts'), ); - const needs = ${{ toJSON(needs) }}; + const needs = JSON.parse(process.env.NEEDS_JSON || '{}'); // GitHub's jobs API is the canonical source because `needs.live` // collapses every matrix target into one result and has no job URL. diff --git a/.github/workflows/platform-vitest-main.yaml b/.github/workflows/platform-vitest-main.yaml index 79934ba0c63..16c30fed274 100644 --- a/.github/workflows/platform-vitest-main.yaml +++ b/.github/workflows/platform-vitest-main.yaml @@ -164,203 +164,118 @@ jobs: env: WSL_DISTRO: Ubuntu WSL_TEST_USER: nemoclaw-ci + TRUSTED_WSL_HELPER: ${{ github.workspace }}\trusted-wsl-ci\tools\wsl\ci-helper.ps1 steps: - name: Force LF line endings for checkout shell: powershell run: git config --global core.autocrlf false - - name: Checkout + - name: Check out the trusted WSL helper + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.workflow_sha }} + path: trusted-wsl-ci + persist-credentials: false + sparse-checkout: | + tools/wsl/ci-helper.ps1 + sparse-checkout-cone-mode: false + + - name: Check out candidate source uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + path: source fetch-depth: 0 persist-credentials: false - 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:TRUSTED_WSL_HELPER" + $null = Set-WslWorkflowPaths ` + -Workspace "$env:GITHUB_WORKSPACE\source" ` + -WorkdirPrefix '/tmp/nemoclaw-wsl-vitest' ` + -RunId $env:GITHUB_RUN_ID ` + -RunAttempt $env:GITHUB_RUN_ATTEMPT ` + -EnvironmentFile $env:GITHUB_ENV - 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) - } + . "$env:TRUSTED_WSL_HELPER" + Ensure-WslDistro -Distro $env:WSL_DISTRO - name: Install Ubuntu dependencies shell: powershell run: | - $script = @' - set -euo pipefail - test_user="${1:?missing WSL test user}" - 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 python3-venv ripgrep rsync tar unzip xz-utils - if ! id -u "$test_user" >/dev/null 2>&1; then - useradd --create-home --shell /bin/bash "$test_user" - 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 --user root -- bash -l $wslTmp $env:WSL_TEST_USER + . "$env:TRUSTED_WSL_HELPER" + $packages = @( + 'bash', 'ca-certificates', 'curl', 'git', 'jq', 'lsb-release', 'make', + 'python3', 'python3-pip', 'python3-venv', 'ripgrep', 'rsync', 'tar', 'unzip', 'xz-utils' + ) + Install-WslUbuntuDependencies -Distro $env:WSL_DISTRO -Packages $packages -TestUser $env:WSL_TEST_USER - name: Install Node.js 22 in WSL shell: powershell run: | - $script = @' - set -euo pipefail - node_version="22.23.1" - case "$(uname -m)" in - x86_64) - node_arch="x64" - node_sha256="9749e988f437343b7fa832c69ded82a312e41a03116d766797ac14f6f9eee578" - ;; - aarch64 | arm64) - node_arch="arm64" - node_sha256="0294e8b915ab75f92c7513d2fcb830ae06e10684e6c603e99a87dbf8835389c1" - ;; - *) - echo "Unsupported Node.js architecture: $(uname -m)" >&2 - exit 1 - ;; - esac - node_url="https://nodejs.org/dist/v${node_version}/node-v${node_version}-linux-${node_arch}.tar.xz" - temp_dir="$(mktemp -d)" - trap 'rm -rf "$temp_dir"' EXIT - archive="$temp_dir/node.tar.xz" - curl --fail --show-error --silent --location \ - --proto '=https' --tlsv1.2 \ - --connect-timeout 15 --max-time 180 \ - --retry 3 --retry-delay 2 --retry-max-time 240 --retry-all-errors \ - --output "$archive" "$node_url" - printf '%s %s\n' "$node_sha256" "$archive" | sha256sum --check --status || { - echo "Node.js archive checksum verification failed" >&2 - exit 1 - } - tar --extract --xz --file "$archive" --directory /usr/local --strip-components=1 - test "$(node --version)" = "v${node_version}" - 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 --user root -- bash -l $wslTmp + . "$env:TRUSTED_WSL_HELPER" + Install-WslNode -Distro $env:WSL_DISTRO - 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 - chown -R '$($env:WSL_TEST_USER):$($env:WSL_TEST_USER)' '$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 --user root -- bash -l $wslTmp + . "$env:TRUSTED_WSL_HELPER" + Sync-WslCheckout ` + -Distro $env:WSL_DISTRO ` + -Checkout $env:WSL_CHECKOUT_DIR ` + -Workdir $env:WSL_WORKDIR ` + -Owner $env:WSL_TEST_USER - name: Install dependencies and build in WSL shell: powershell run: | + . "$env:TRUSTED_WSL_HELPER" + $testUser = ConvertTo-BashLiteral -Value $env:WSL_TEST_USER + $workdir = ConvertTo-BashLiteral -Value $env:WSL_WORKDIR $script = @" set -euo pipefail - id -un | grep -Fxq '$env:WSL_TEST_USER' - cd '$env:WSL_WORKDIR' + id -un | grep -Fxq $testUser + cd $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 --user $env:WSL_TEST_USER -- bash -l $wslTmp + Invoke-WslScript -Distro $env:WSL_DISTRO -User $env:WSL_TEST_USER -Script $script - name: Run full Vitest suite in WSL shell: powershell run: | + . "$env:TRUSTED_WSL_HELPER" + $testUser = ConvertTo-BashLiteral -Value $env:WSL_TEST_USER + $workdir = ConvertTo-BashLiteral -Value $env:WSL_WORKDIR $script = @" set -euo pipefail - id -un | grep -Fxq '$env:WSL_TEST_USER' - cd '$env:WSL_WORKDIR' + id -un | grep -Fxq $testUser + cd $workdir export NEMOCLAW_EXEC_TIMEOUT=60000 export NEMOCLAW_TEST_TIMEOUT=60000 npx vitest run --testTimeout 60000 --shard='${{ matrix.shard }}/4' "@ - $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 --user $env:WSL_TEST_USER -- bash -l $wslTmp + Invoke-WslScript -Distro $env:WSL_DISTRO -User $env:WSL_TEST_USER -Script $script + # Keep the root-only test selection and identity checks inline. Only the + # trusted helper constructs WSL commands and transfers scripts. - name: Run root-required Vitest contracts in WSL if: ${{ matrix.shard == 1 }} shell: powershell run: | + . "$env:TRUSTED_WSL_HELPER" + $workdir = ConvertTo-BashLiteral -Value $env:WSL_WORKDIR $script = @" set -euo pipefail id -u | grep -Fxq '0' - cd '$env:WSL_WORKDIR' + cd $workdir if ! getent group sandbox >/dev/null; then groupadd --system sandbox fi @@ -378,7 +293,4 @@ jobs: test/nemoclaw-start-perms.test.ts \ -t 'requires both fixed files to match|reclaims a root-owned collapsed config|leaves a root-owned recovery baseline untouched' "@ - $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 --user root -- bash -l $wslTmp + Invoke-WslScript -Distro $env:WSL_DISTRO -User root -Script $script diff --git a/.github/workflows/wsl-e2e.yaml b/.github/workflows/wsl-e2e.yaml index 69e27906f00..492967c9d82 100644 --- a/.github/workflows/wsl-e2e.yaml +++ b/.github/workflows/wsl-e2e.yaml @@ -12,6 +12,7 @@ on: - "scripts/**" - "test/**" - ".github/workflows/wsl-e2e.yaml" + - "tools/wsl/**" - "package.json" - "vitest.config.ts" push: @@ -31,6 +32,7 @@ jobs: timeout-minutes: 90 env: WSL_DISTRO: Ubuntu + TRUSTED_WSL_HELPER: ${{ github.workspace }}\trusted-wsl-ci\tools\wsl\ci-helper.ps1 NEMOCLAW_NON_INTERACTIVE: "1" NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1" NEMOCLAW_RECREATE_SANDBOX: "1" @@ -40,198 +42,91 @@ jobs: shell: powershell run: git config --global core.autocrlf false - - name: Checkout + - name: Check out the trusted WSL helper uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.workflow_sha }} + path: trusted-wsl-ci + persist-credentials: false + sparse-checkout: | + tools/wsl/ci-helper.ps1 + sparse-checkout-cone-mode: false + + - name: Check out candidate source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + path: source + persist-credentials: false - 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:TRUSTED_WSL_HELPER" + $null = Set-WslWorkflowPaths ` + -Workspace "$env:GITHUB_WORKSPACE\source" ` + -WorkdirPrefix '/tmp/nemoclaw-wsl-workdir' ` + -RunId $env:GITHUB_RUN_ID ` + -RunAttempt $env:GITHUB_RUN_ATTEMPT ` + -EnvironmentFile $env:GITHUB_ENV - 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) - } + . "$env:TRUSTED_WSL_HELPER" + Ensure-WslDistro -Distro $env:WSL_DISTRO - 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" + . "$env:TRUSTED_WSL_HELPER" + Invoke-WslScript -Distro $env:WSL_DISTRO -Script "uname -a`ncat /etc/os-release" - 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 + . "$env:TRUSTED_WSL_HELPER" + $packages = @( + 'bash', 'ca-certificates', 'curl', 'git', 'jq', 'lsb-release', 'make', + 'python3', 'python3-pip', 'rsync', 'tar', 'unzip', 'xz-utils' + ) + Install-WslUbuntuDependencies -Distro $env:WSL_DISTRO -Packages $packages - name: Install Node.js 22 in WSL shell: powershell run: | - $script = @' - set -euo pipefail - node_version="22.23.1" - case "$(uname -m)" in - x86_64) - node_arch="x64" - node_sha256="9749e988f437343b7fa832c69ded82a312e41a03116d766797ac14f6f9eee578" - ;; - aarch64 | arm64) - node_arch="arm64" - node_sha256="0294e8b915ab75f92c7513d2fcb830ae06e10684e6c603e99a87dbf8835389c1" - ;; - *) - echo "Unsupported Node.js architecture: $(uname -m)" >&2 - exit 1 - ;; - esac - node_url="https://nodejs.org/dist/v${node_version}/node-v${node_version}-linux-${node_arch}.tar.xz" - temp_dir="$(mktemp -d)" - trap 'rm -rf "$temp_dir"' EXIT - archive="$temp_dir/node.tar.xz" - curl --fail --show-error --silent --location \ - --proto '=https' --tlsv1.2 \ - --connect-timeout 15 --max-time 180 \ - --retry 3 --retry-delay 2 --retry-max-time 240 --retry-all-errors \ - --output "$archive" "$node_url" - printf '%s %s\n' "$node_sha256" "$archive" | sha256sum --check --status || { - echo "Node.js archive checksum verification failed" >&2 - exit 1 - } - tar --extract --xz --file "$archive" --directory /usr/local --strip-components=1 - test "$(node --version)" = "v${node_version}" - 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 + . "$env:TRUSTED_WSL_HELPER" + Install-WslNode -Distro $env:WSL_DISTRO - 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 + . "$env:TRUSTED_WSL_HELPER" + Sync-WslCheckout ` + -Distro $env:WSL_DISTRO ` + -Checkout $env:WSL_CHECKOUT_DIR ` + -Workdir $env:WSL_WORKDIR - name: Install project dependencies and build plugin shell: powershell run: | + . "$env:TRUSTED_WSL_HELPER" + $workdir = ConvertTo-BashLiteral -Value $env:WSL_WORKDIR $script = @" set -euo pipefail - cd '$env:WSL_WORKDIR' + cd $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 + Invoke-WslScript -Distro $env:WSL_DISTRO -Script $script - name: Detect Docker availability in WSL id: docker shell: powershell run: | + . "$env:TRUSTED_WSL_HELPER" $script = @' if docker info >/dev/null 2>&1; then echo DOCKER_OK=1 @@ -239,10 +134,7 @@ jobs: 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 + $result = Invoke-WslScript -Distro $env:WSL_DISTRO -Script $script -CaptureOutput 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' @@ -258,22 +150,24 @@ jobs: NVIDIA_INFERENCE_API_KEY: ${{ secrets.NVIDIA_INFERENCE_API_KEY }} GITHUB_TOKEN: ${{ github.token }} run: | + . "$env:TRUSTED_WSL_HELPER" + $workdir = ConvertTo-BashLiteral -Value $env:WSL_WORKDIR + $exports = @( + 'export NVIDIA_INFERENCE_API_KEY=' + (ConvertTo-BashLiteral -Value ([string]$env:NVIDIA_INFERENCE_API_KEY)) + 'export GITHUB_TOKEN=' + (ConvertTo-BashLiteral -Value ([string]$env:GITHUB_TOKEN)) + 'export NEMOCLAW_NON_INTERACTIVE=' + (ConvertTo-BashLiteral -Value $env:NEMOCLAW_NON_INTERACTIVE) + 'export NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=' + (ConvertTo-BashLiteral -Value $env:NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE) + 'export NEMOCLAW_RECREATE_SANDBOX=' + (ConvertTo-BashLiteral -Value $env:NEMOCLAW_RECREATE_SANDBOX) + 'export NEMOCLAW_SANDBOX_NAME=' + (ConvertTo-BashLiteral -Value $env:NEMOCLAW_SANDBOX_NAME) + ) -join "`n" $script = @" set -euo pipefail - cd '$env:WSL_WORKDIR' - export NVIDIA_INFERENCE_API_KEY='$env:NVIDIA_INFERENCE_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' + cd $workdir + $exports export NEMOCLAW_RUN_LIVE_E2E=1 npx vitest run --project e2e-live test/e2e/live/full-e2e.test.ts --silent=false --reporter=default "@ - $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 + Invoke-WslScript -Distro $env:WSL_DISTRO -Script $script - name: Explain skipped full E2E if: steps.docker.outputs.docker_ok != 'true' diff --git a/ci/source-shape-test-budget.json b/ci/source-shape-test-budget.json index 86726f46490..969a13b0438 100644 --- a/ci/source-shape-test-budget.json +++ b/ci/source-shape-test-budget.json @@ -453,7 +453,12 @@ }, { "file": "test/platform-vitest-main-workflow.test.ts", - "test": "pins and verifies the Node.js archive in both WSL workflows", + "test": "pins and verifies the Node.js archive in the trusted WSL helper", + "category": "security" + }, + { + "file": "test/platform-vitest-main-workflow.test.ts", + "test": "loads the WSL helper from trusted revisions before candidate execution (#6958)", "category": "security" }, { diff --git a/test/e2e/support/e2e-operations-workflow-boundary.test.ts b/test/e2e/support/e2e-operations-workflow-boundary.test.ts index 3592df97c53..6af2066da71 100644 --- a/test/e2e/support/e2e-operations-workflow-boundary.test.ts +++ b/test/e2e/support/e2e-operations-workflow-boundary.test.ts @@ -65,6 +65,89 @@ describe("E2E operations workflow boundary", () => { ); }); + it("rejects missing NEEDS_JSON environment data (#6952)", () => { + const workflow = readE2eOperationsWorkflow(); + const report = workflow.jobs["report-to-pr"].steps!.find( + (step) => step.name === "Post E2E target results to PR", + )!; + delete report.env?.NEEDS_JSON; + + expect(validateE2eOperationsWorkflow(workflow)).toContain( + "report-to-pr must pass needs as environment data without script interpolation", + ); + }); + + it("rejects malformed NEEDS_JSON environment data (#6952)", () => { + const workflow = readE2eOperationsWorkflow(); + const scorecard = workflow.jobs.scorecard.steps!.find( + (step) => step.name === "Generate E2E scorecard", + )!; + scorecard.env!.NEEDS_JSON = "${{ toJSON(needs.generate-matrix) }}"; + + expect(validateE2eOperationsWorkflow(workflow)).toContain( + "scorecard generator must pass needs as environment data without script interpolation", + ); + }); + + it("rejects needs interpolation in GitHub Script source (#6952)", () => { + const workflow = readE2eOperationsWorkflow(); + const report = workflow.jobs["report-to-pr"].steps!.find( + (step) => step.name === "Post E2E target results to PR", + )!; + report.with!.script = `${String(report.with!.script)} +const interpolatedNeeds = \${{ toJSON ( needs ) }}; +`; + + expect(validateE2eOperationsWorkflow(workflow)).toContain( + "report-to-pr must pass needs as environment data without script interpolation", + ); + }); + + it("rejects a commented NEEDS_JSON assignment (#6952)", () => { + const workflow = readE2eOperationsWorkflow(); + const report = workflow.jobs["report-to-pr"].steps!.find( + (step) => step.name === "Post E2E target results to PR", + )!; + report.with!.script = String(report.with!.script).replace( + "const needs = JSON.parse(process.env.NEEDS_JSON || '{}');", + "// const needs = JSON.parse(process.env.NEEDS_JSON || '{}');\nconst needs = {};", + ); + + expect(validateE2eOperationsWorkflow(workflow)).toContain( + "report-to-pr must pass needs as environment data without script interpolation", + ); + }); + + it("rejects NEEDS_JSON parsing assigned to an unrelated variable (#6952)", () => { + const workflow = readE2eOperationsWorkflow(); + const scorecard = workflow.jobs.scorecard.steps!.find( + (step) => step.name === "Generate E2E scorecard", + )!; + scorecard.with!.script = String(scorecard.with!.script).replace( + "const needs = JSON.parse(process.env.NEEDS_JSON || '{}');", + "const scorecardNeeds = JSON.parse(process.env.NEEDS_JSON || '{}');", + ); + + expect(validateE2eOperationsWorkflow(workflow)).toContain( + "scorecard generator must pass needs as environment data without script interpolation", + ); + }); + + it("rejects a lookalike NEEDS_JSON variable (#6952)", () => { + const workflow = readE2eOperationsWorkflow(); + const report = workflow.jobs["report-to-pr"].steps!.find( + (step) => step.name === "Post E2E target results to PR", + )!; + report.with!.script = String(report.with!.script).replace( + "process.env.NEEDS_JSON", + "process.env.NEEDS_JSON_BAD", + ); + + expect(validateE2eOperationsWorkflow(workflow)).toContain( + "report-to-pr must pass needs as environment data without script interpolation", + ); + }); + it("pins the scorecard's current-run progress artifact action", () => { const workflow = readE2eOperationsWorkflow(); const download = workflow.jobs.scorecard.steps!.find( @@ -552,10 +635,7 @@ describe("E2E operations workflow boundary", () => { }); it("executes the scorecard workflow body and emits advisory budget warnings", async () => { - const script = workflowScript("scorecard", "Generate E2E scorecard").replace( - "${{ toJSON(needs) }}", - JSON.stringify({ "generate-matrix": { result: "success" } }), - ); + const script = workflowScript("scorecard", "Generate E2E scorecard"); const warning = vi.fn(); const setOutput = vi.fn(); const summary = { @@ -615,6 +695,7 @@ describe("E2E operations workflow boundary", () => { EXPLICIT_ONLY_JOBS: "", GITHUB_WORKSPACE: "/workspace", JOBS: "", + NEEDS_JSON: JSON.stringify({ "generate-matrix": { result: "success" } }), RUNTIME_ARTIFACTS: "/runner/e2e-runtime-audit", RUNTIME_SUMMARY_FILE: "/runner/e2e-runtime-summary.json", TARGETS: "", @@ -675,10 +756,7 @@ describe("E2E operations workflow boundary", () => { }); it("keeps scorecard outputs available when a progress artifact is invalid", async () => { - const script = workflowScript("scorecard", "Generate E2E scorecard").replace( - "${{ toJSON(needs) }}", - JSON.stringify({ "generate-matrix": { result: "success" } }), - ); + const script = workflowScript("scorecard", "Generate E2E scorecard"); const warning = vi.fn(); const setOutput = vi.fn(); const summary = { @@ -733,6 +811,7 @@ describe("E2E operations workflow boundary", () => { EXPLICIT_ONLY_JOBS: "", GITHUB_WORKSPACE: "/workspace", JOBS: "", + NEEDS_JSON: JSON.stringify({ "generate-matrix": { result: "success" } }), RUNTIME_ARTIFACTS: "/runner/e2e-runtime-audit", RUNTIME_SUMMARY_FILE: "/runner/e2e-runtime-summary.json", TARGETS: "", diff --git a/test/helpers/vitest-watch-triggers.ts b/test/helpers/vitest-watch-triggers.ts index de360dacfce..fae85644d77 100644 --- a/test/helpers/vitest-watch-triggers.ts +++ b/test/helpers/vitest-watch-triggers.ts @@ -128,11 +128,17 @@ export const vitestWatchTriggerPatterns: VitestWatchTriggerPattern[] = [ }, { pattern: - /(?:^|\/)(?:\.github\/workflows\/platform-vitest-main\.yaml|ci\/platform-vitest-macos-requirements\.lock)$/, + /(?:^|\/)(?:\.github\/workflows\/(?:platform-vitest-main|wsl-e2e)\.yaml|tools\/wsl\/ci-helper\.ps1)$/, + testsToRun: runTests( + "test/platform-vitest-main-workflow.test.ts", + "test/wsl-ci-helper.test.ts", + ), + }, + { + pattern: /(?:^|\/)ci\/platform-vitest-macos-requirements\.lock$/, testsToRun: runTests("test/platform-vitest-main-workflow.test.ts"), }, ]; - export function resolveVitestWatchTests(file: string): string[] { const normalized = file.replaceAll("\\", "/"); const tests = new Set(); diff --git a/test/platform-vitest-main-workflow.test.ts b/test/platform-vitest-main-workflow.test.ts index 1b0c72bf511..8551e8cc16e 100644 --- a/test/platform-vitest-main-workflow.test.ts +++ b/test/platform-vitest-main-workflow.test.ts @@ -13,9 +13,11 @@ import { const WORKFLOW_PATH = ".github/workflows/platform-vitest-main.yaml"; const WSL_E2E_WORKFLOW_PATH = ".github/workflows/wsl-e2e.yaml"; +const WSL_HELPER_PATH = "tools/wsl/ci-helper.ps1"; const MACOS_REQUIREMENTS_PATH = "ci/platform-vitest-macos-requirements.lock"; const workflow = readYaml(WORKFLOW_PATH); const wslE2eWorkflow = readYaml(WSL_E2E_WORKFLOW_PATH); +const wslHelperSource = readRepoText(WSL_HELPER_PATH); function job(name: string): WorkflowJob { const candidate = workflow.jobs[name]; @@ -30,14 +32,9 @@ function step(jobName: string, name: string): WorkflowStep { } describe("platform Vitest main workflow", () => { - // source-shape-contract: security -- WSL jobs install only checksum-verified official Node.js archives - it("pins and verifies the Node.js archive in both WSL workflows", () => { - const installSteps = [ - step("wsl-vitest", "Install Node.js 22 in WSL"), - wslE2eWorkflow.jobs["wsl-e2e"]?.steps?.find( - (entry) => entry.name === "Install Node.js 22 in WSL", - ), - ]; + // source-shape-contract: security -- The trusted helper installs only checksum-verified official Node.js archives + it("pins and verifies the Node.js archive in the trusted WSL helper", () => { + const installSteps = [{ run: wslHelperSource }]; for (const installStep of installSteps) { expect(installStep, "missing WSL Node.js install step").toBeDefined(); @@ -66,6 +63,74 @@ describe("platform Vitest main workflow", () => { expect(run).not.toContain("deb.nodesource.com"); expect(run).not.toMatch(/\bcurl\b[^\n]*\|\s*bash\b/u); } + expect(step("wsl-vitest", "Install Node.js 22 in WSL").run).toContain("Install-WslNode"); + expect( + wslE2eWorkflow.jobs["wsl-e2e"]?.steps?.find( + (entry) => entry.name === "Install Node.js 22 in WSL", + )?.run, + ).toContain("Install-WslNode"); + }); + + // source-shape-contract: security -- Sparse immutable helper checkouts must precede candidate code before root-capable WSL execution + it("loads the WSL helper from trusted revisions before candidate execution (#6958)", () => { + expect( + (workflow as Workflow & { on?: Record }).on, + "platform main-watch workflow must not execute candidate code on pull requests", + ).not.toHaveProperty("pull_request"); + + const cases = [ + { + helperRef: "${{ github.workflow_sha }}", + job: job("wsl-vitest"), + }, + { + helperRef: + "${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.workflow_sha }}", + job: wslE2eWorkflow.jobs["wsl-e2e"], + }, + ]; + + for (const workflowCase of cases) { + expect(workflowCase.job, "missing WSL job").toBeDefined(); + const steps = workflowCase.job?.steps ?? []; + const trustedCheckout = steps.find( + (entry) => entry.name === "Check out the trusted WSL helper", + ); + const candidateCheckout = steps.find((entry) => entry.name === "Check out candidate source"); + expect(trustedCheckout?.with).toMatchObject({ + ref: workflowCase.helperRef, + path: "trusted-wsl-ci", + "persist-credentials": false, + "sparse-checkout": `${WSL_HELPER_PATH}\n`, + "sparse-checkout-cone-mode": false, + }); + expect(candidateCheckout?.with).toMatchObject({ + path: "source", + "persist-credentials": false, + }); + expect(steps.map((entry) => entry.name)).not.toContain("Detect trusted WSL helper"); + expect(steps.map((entry) => entry.name)).not.toContain( + "Explain deferred trusted WSL helper rollout", + ); + expect( + steps.some((entry) => (entry.if ?? "").includes("steps.helper.outputs.present")), + "missing trusted helper must fail instead of skipping candidate validation", + ).toBe(false); + expect(steps.indexOf(trustedCheckout!)).toBeLessThan(steps.indexOf(candidateCheckout!)); + + for (const entry of steps.filter((candidate) => + /(?:Ensure|Install|Invoke|Sync)-Wsl/u.test(candidate.run ?? ""), + )) { + expect(entry.run, `${entry.name} must load the trusted helper`).toContain( + '. "$env:TRUSTED_WSL_HELPER"', + ); + } + } + + expect(readRepoText(WORKFLOW_PATH)).not.toMatch(/WriteAllText|wslpath|wsl\s+--install/u); + expect(readRepoText(WSL_E2E_WORKFLOW_PATH)).not.toMatch( + /WriteAllText|wslpath|wsl\s+--install/u, + ); }); // source-shape-contract: compatibility -- macOS must use the same modern shell/tool semantics as the Linux sandbox fixtures @@ -134,7 +199,7 @@ describe("platform Vitest main workflow", () => { // source-shape-contract: security -- ordinary tests stay non-root while the five UID-0 contracts remain isolated it("keeps the WSL suite unprivileged with explicit root-only contracts", () => { const stepNames = job("wsl-vitest").steps?.map((entry) => entry.name) ?? []; - const checkout = step("wsl-vitest", "Checkout"); + const checkout = step("wsl-vitest", "Check out candidate source"); const install = step("wsl-vitest", "Install Ubuntu dependencies").run ?? ""; const fullSuite = step("wsl-vitest", "Run full Vitest suite in WSL").run ?? ""; const rootSuite = step("wsl-vitest", "Run root-required Vitest contracts in WSL").run ?? ""; @@ -151,15 +216,17 @@ describe("platform Vitest main workflow", () => { expect(stepNames.indexOf("Install Ubuntu dependencies")).toBeLessThan( stepNames.indexOf("Run full Vitest suite in WSL"), ); - expect(install).toMatch(/apt-get install[^\n]*\bpython3-venv\b/u); - expect(install).toMatch(/apt-get install[^\n]*\bripgrep\b/u); + expect(install).toContain("Install-WslUbuntuDependencies"); + expect(install).toContain("'python3-venv'"); + expect(install).toContain("'ripgrep'"); + expect(wslHelperSource).toContain("apt-get install -y $packageList"); expect(install).not.toMatch(/\bsudo\b|sudoers|NOPASSWD/u); - expect(fullSuite).toContain("--user $env:WSL_TEST_USER"); + expect(fullSuite).toContain("-User $env:WSL_TEST_USER"); expect(fullSuite).toContain("NEMOCLAW_EXEC_TIMEOUT=60000"); expect(fullSuite).toContain("NEMOCLAW_TEST_TIMEOUT=60000"); expect(fullSuite).toContain("--shard='${{ matrix.shard }}/4'"); expect(fullSuite).not.toMatch(/\bsudo\b|sudoers|NOPASSWD/u); - expect(rootSuite).toContain("--user root"); + expect(rootSuite).toContain("-User root"); expect(step("wsl-vitest", "Run root-required Vitest contracts in WSL").if).toBe( "${{ matrix.shard == 1 }}", ); diff --git a/test/vitest-watch-triggers.test.ts b/test/vitest-watch-triggers.test.ts index 6f14855972d..5d9234cbb2c 100644 --- a/test/vitest-watch-triggers.test.ts +++ b/test/vitest-watch-triggers.test.ts @@ -68,6 +68,7 @@ const OPAQUE_INPUTS = [ ".github/workflows/wsl-e2e.yaml", ".github/workflows/macos-e2e.yaml", ".github/workflows/platform-vitest-main.yaml", + "tools/wsl/ci-helper.ps1", "ci/platform-vitest-macos-requirements.lock", ] as const; @@ -136,6 +137,8 @@ describe("Vitest opaque-input watch triggers", () => { ]); expect(triggeredBy(".github/workflows/wsl-e2e.yaml")).toEqual([ "test/hosted-runner-recovery-workflow.test.ts", + "test/platform-vitest-main-workflow.test.ts", + "test/wsl-ci-helper.test.ts", ]); expect(triggeredBy(".github/workflows/macos-e2e.yaml")).toEqual([ "test/hosted-runner-recovery-workflow.test.ts", @@ -143,6 +146,11 @@ describe("Vitest opaque-input watch triggers", () => { expect(triggeredBy(".github/workflows/platform-vitest-main.yaml")).toEqual([ "test/hosted-runner-recovery-workflow.test.ts", "test/platform-vitest-main-workflow.test.ts", + "test/wsl-ci-helper.test.ts", + ]); + expect(triggeredBy("tools/wsl/ci-helper.ps1")).toEqual([ + "test/platform-vitest-main-workflow.test.ts", + "test/wsl-ci-helper.test.ts", ]); expect(triggeredBy("ci/platform-vitest-macos-requirements.lock")).toEqual([ "test/platform-vitest-main-workflow.test.ts", diff --git a/tools/e2e/operations-workflow-boundary.mts b/tools/e2e/operations-workflow-boundary.mts index c02ae0f67d9..5b2a34a5fb6 100644 --- a/tools/e2e/operations-workflow-boundary.mts +++ b/tools/e2e/operations-workflow-boundary.mts @@ -6,6 +6,7 @@ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { isDeepStrictEqual } from "node:util"; +import ts from "typescript"; import YAML from "yaml"; import { RISK_RULES } from "../advisors/risk-plan.mts"; @@ -48,6 +49,7 @@ const GENERIC_ISSUE_REST_MUTATION = /github\.request\s*\(\s*["'`](?:POST|PATCH|PUT|DELETE)\s+\/repos\/[^/\s]+\/[^/\s]+\/issues(?:\/|\b)/u; const GENERIC_ISSUE_GRAPHQL_MUTATION = /github\.graphql\s*\(\s*["'`]\s*mutation\b[\s\S]*?\b(?:addComment|closeIssue|createIssue|reopenIssue|updateIssue)\b/u; +const NEEDS_INTERPOLATION = /\$\{\{\s*toJSON\s*\(\s*needs\s*\)\s*\}\}/iu; type WorkflowStep = { "continue-on-error"?: boolean; @@ -122,6 +124,64 @@ function executableSource(job: WorkflowJob): string { .join("\n"); } +function isNeedsEnvironmentAccess(expression: ts.Expression): boolean { + if (!ts.isPropertyAccessExpression(expression) || expression.name.text !== "NEEDS_JSON") { + return false; + } + const environment = expression.expression; + return ( + ts.isPropertyAccessExpression(environment) && + environment.name.text === "env" && + ts.isIdentifier(environment.expression) && + environment.expression.text === "process" + ); +} + +function isNeedsEnvironmentParse(expression: ts.Expression): boolean { + if (!ts.isCallExpression(expression) || expression.arguments.length !== 1) { + return false; + } + const parser = expression.expression; + const input = expression.arguments[0]; + return ( + ts.isPropertyAccessExpression(parser) && + ts.isIdentifier(parser.expression) && + parser.expression.text === "JSON" && + parser.name.text === "parse" && + ts.isBinaryExpression(input) && + input.operatorToken.kind === ts.SyntaxKind.BarBarToken && + isNeedsEnvironmentAccess(input.left) && + ts.isStringLiteral(input.right) && + input.right.text === "{}" + ); +} + +function assignsNeedsFromEnvironment(script: string): boolean { + const source = ts.createSourceFile( + "github-script.js", + script, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.JS, + ); + let found = false; + const visit = (node: ts.Node): void => { + if ( + ts.isVariableDeclaration(node) && + ts.isIdentifier(node.name) && + node.name.text === "needs" && + node.initializer !== undefined && + isNeedsEnvironmentParse(node.initializer) + ) { + found = true; + return; + } + ts.forEachChild(node, visit); + }; + visit(source); + return found; +} + function requirePinnedAction(errors: string[], step: WorkflowStep, owner: string): void { if (!FULL_SHA_ACTION.test(step.uses ?? "")) { errors.push(`${owner} must pin its action to a full SHA`); @@ -135,6 +195,15 @@ function requireNode24GithubScript(errors: string[], step: WorkflowStep, owner: } } +function passesNeedsAsEnvironmentData(step: WorkflowStep): boolean { + const script = String(step.with?.script ?? ""); + return ( + step.env?.NEEDS_JSON === "${{ toJSON(needs) }}" && + !NEEDS_INTERPOLATION.test(script) && + assignsNeedsFromEnvironment(script) + ); +} + function validateControllerAuthorization( errors: string[], workflow: OperationsWorkflow, @@ -541,6 +610,11 @@ function validateIssueRoutingRetirement(errors: string[], workflow: OperationsWo } requireNode24GithubScript(errors, report, "report-to-pr"); const reportScript = String(report.with?.script ?? ""); + if (!passesNeedsAsEnvironmentData(report)) { + errors.push( + "report-to-pr must pass needs as environment data without script interpolation", + ); + } const commentCalls = jobSource.match(/github\.rest\.issues\.createComment\s*\(/gu); const issueNamespaceReferences = reportScript.match(/github\.rest\.issues\b/gu); const prScopedComment = @@ -653,6 +727,11 @@ function validateScorecard(errors: string[], workflow: OperationsWorkflow): void const generate = findStep(job, "Generate E2E scorecard"); requireNode24GithubScript(errors, generate, "scorecard generator"); const generateScript = String(generate.with?.script ?? ""); + if (!passesNeedsAsEnvironmentData(generate)) { + errors.push( + "scorecard generator must pass needs as environment data without script interpolation", + ); + } for (const fragment of [ "scripts/scorecard/coordinate-scorecard.mts", "buildScorecard",