NVIDIA/NemoClaw

9 workflows · maturity 33% · 5 patterns · GitHub ↗

Security 25/100

Practices

○ Matrix✓ Permissions○ Security scan○ AI review○ Cache✓ Concurrency○ Reusable workflows

Detected patterns

Security dimensions

permissions
25
security scan
0
supply chain
0
secret handling
0
harden runner
0

Workflows (9)

base-image perms .github/workflows/base-image.yaml
Triggers
push, workflow_dispatch
Runs on
ubuntu-latest
Jobs
build-and-push
Actions
docker/setup-qemu-action, docker/setup-buildx-action, docker/login-action, docker/metadata-action, docker/build-push-action
View raw YAML
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

# Build and push the sandbox base image to GHCR.
#
# Triggers:
#   - Push to main when Dockerfile.base changes (new apt pkgs, openclaw bump, etc.)
#   - Manual dispatch for ad-hoc rebuilds
#
# The base image contains the expensive, rarely-changing layers (apt, gosu,
# user setup, openclaw CLI). The production Dockerfile layers PR-specific
# code on top via: FROM ghcr.io/nvidia/nemoclaw/sandbox-base:<tag>

name: base-image

on:
  push:
    branches: [main]
    paths:
      - "Dockerfile.base"
  workflow_dispatch:

permissions:
  contents: read
  packages: write

concurrency:
  group: base-image
  cancel-in-progress: true

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: nvidia/nemoclaw/sandbox-base

jobs:
  build-and-push:
    if: github.repository == 'NVIDIA/NemoClaw'
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - name: Checkout
        uses: actions/checkout@v6

      - name: Set up QEMU (arm64 emulation)
        uses: docker/setup-qemu-action@v3

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Log in to GHCR
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=raw,value=latest
            type=sha,prefix=,format=short

      - name: Build and push
        uses: docker/build-push-action@v6
        with:
          context: .
          file: Dockerfile.base
          platforms: linux/amd64,linux/arm64
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
commit-lint perms .github/workflows/commit-lint.yaml
Triggers
pull_request
Runs on
ubuntu-latest
Jobs
commit-lint
Commands
  • npm install --ignore-scripts
  • printf '%s\n' "$PR_TITLE" | npx commitlint --verbose
View raw YAML
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

name: commit-lint

on:
  pull_request:
    types: [opened, synchronize, reopened, edited]

permissions:
  contents: read

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  commit-lint:
    runs-on: ubuntu-latest
    timeout-minutes: 5
    steps:
      - name: Checkout
        uses: actions/checkout@v6

      - name: Setup Node.js
        uses: actions/setup-node@v6
        with:
          node-version: "22"
          cache: npm

      - name: Install dependencies
        run: npm install --ignore-scripts

      - name: Lint PR title
        env:
          PR_TITLE: ${{ github.event.pull_request.title }}
        run: printf '%s\n' "$PR_TITLE" | npx commitlint --verbose
docker-pin-check perms .github/workflows/docker-pin-check.yaml
Triggers
schedule, workflow_dispatch
Runs on
ubuntu-latest
Jobs
check-pin
Commands
  • bash scripts/update-docker-pin.sh --check DOCKERFILE=Dockerfile.base bash scripts/update-docker-pin.sh --check
View raw YAML
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Weekly check that the pinned Dockerfile base-image digest is still current.
# Fails with an actionable message when a newer node:22-slim is available.

name: docker-pin-check

on:
  schedule:
    # Every Monday at 09:00 UTC
    - cron: "0 9 * * 1"
  workflow_dispatch:

permissions:
  contents: read

jobs:
  check-pin:
    if: github.repository == 'NVIDIA/NemoClaw'
    runs-on: ubuntu-latest
    timeout-minutes: 5
    steps:
      - name: Checkout
        uses: actions/checkout@v6

      - name: Check Dockerfile base-image pin
        run: |
          bash scripts/update-docker-pin.sh --check
          DOCKERFILE=Dockerfile.base bash scripts/update-docker-pin.sh --check
docs-preview-deploy perms .github/workflows/docs-preview-deploy.yaml
Triggers
workflow_run
Runs on
ubuntu-latest
Jobs
deploy
Actions
rossjrw/pr-preview-action, marocchino/sticky-pull-request-comment, rossjrw/pr-preview-action, marocchino/sticky-pull-request-comment
Commands
  • echo "pr-number=$(cat pr-number.txt)" >> "$GITHUB_OUTPUT" if [ -f pr-action.txt ]; then echo "action=$(cat pr-action.txt)" >> "$GITHUB_OUTPUT" else echo "action=deploy" >> "$GITHUB_OUTPUT" fi if [ -f same-repo.txt ]; then echo "same-repo=$(cat same-repo.txt)" >> "$GITHUB_OUTPUT" else echo "same-repo=false" >> "$GITHUB_OUTPUT" fi
  • echo "::notice::Skipping preview deploy for fork PR #${{ steps.meta.outputs.pr-number }}" exit 0
View raw YAML
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

# Step 2 of 2: Deploy (or clean up) the docs preview built by docs-preview-pr.yaml.
# Runs via workflow_run so it always has write access to gh-pages.
# Only deploys previews for same-repo PRs to prevent fork PRs from
# publishing arbitrary content to the GitHub Pages domain.

name: Docs PR Preview Deploy

on:
  workflow_run:
    workflows: ["Docs PR Preview"]
    types: [completed]

permissions:
  contents: write
  pull-requests: write
  actions: read

jobs:
  deploy:
    if: github.event.workflow_run.conclusion == 'success'
    runs-on: ubuntu-latest
    timeout-minutes: 5
    steps:
      # Checkout first so subsequent artifact downloads are not clobbered.
      - name: Checkout repository
        uses: actions/checkout@v6

      - name: Download PR metadata
        uses: actions/download-artifact@v8
        with:
          name: docs-preview-metadata
          run-id: ${{ github.event.workflow_run.id }}
          github-token: ${{ secrets.GITHUB_TOKEN }}

      - name: Read PR metadata
        id: meta
        run: |
          echo "pr-number=$(cat pr-number.txt)" >> "$GITHUB_OUTPUT"
          if [ -f pr-action.txt ]; then
            echo "action=$(cat pr-action.txt)" >> "$GITHUB_OUTPUT"
          else
            echo "action=deploy" >> "$GITHUB_OUTPUT"
          fi
          if [ -f same-repo.txt ]; then
            echo "same-repo=$(cat same-repo.txt)" >> "$GITHUB_OUTPUT"
          else
            echo "same-repo=false" >> "$GITHUB_OUTPUT"
          fi

      - name: Skip fork PRs
        if: steps.meta.outputs.action != 'closed' && steps.meta.outputs.same-repo != 'true'
        run: |
          echo "::notice::Skipping preview deploy for fork PR #${{ steps.meta.outputs.pr-number }}"
          exit 0

      - name: Download docs artifact
        if: steps.meta.outputs.action != 'closed' && steps.meta.outputs.same-repo == 'true'
        uses: actions/download-artifact@v8
        with:
          name: docs-preview
          path: docs-preview-html/
          run-id: ${{ github.event.workflow_run.id }}
          github-token: ${{ secrets.GITHUB_TOKEN }}

      - name: Deploy preview
        if: steps.meta.outputs.action != 'closed' && steps.meta.outputs.same-repo == 'true'
        uses: rossjrw/pr-preview-action@ffa7509e91a3ec8dfc2e5536c4d5c1acdf7a6de9  # v1
        with:
          source-dir: ./docs-preview-html/
          preview-branch: gh-pages
          umbrella-dir: pr-preview
          action: deploy
          pr-number: ${{ steps.meta.outputs.pr-number }}
          comment: false

      - name: Post preview comment
        if: steps.meta.outputs.action != 'closed' && steps.meta.outputs.same-repo == 'true'
        uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405  # v2
        with:
          header: pr-preview
          number: ${{ steps.meta.outputs.pr-number }}
          message: |
            :rocket: **Docs preview ready!**

            https://NVIDIA.github.io/NemoClaw/pr-preview/pr-${{ steps.meta.outputs.pr-number }}/

      - name: Remove preview
        if: steps.meta.outputs.action == 'closed'
        uses: rossjrw/pr-preview-action@ffa7509e91a3ec8dfc2e5536c4d5c1acdf7a6de9  # v1
        with:
          preview-branch: gh-pages
          umbrella-dir: pr-preview
          action: remove
          pr-number: ${{ steps.meta.outputs.pr-number }}
          comment: false

      - name: Remove preview comment
        if: steps.meta.outputs.action == 'closed'
        uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405  # v2
        with:
          header: pr-preview
          number: ${{ steps.meta.outputs.pr-number }}
          delete: true
docs-preview-pr perms .github/workflows/docs-preview-pr.yaml
Triggers
pull_request
Runs on
ubuntu-latest, ubuntu-latest
Jobs
build, close
Actions
astral-sh/setup-uv
Commands
  • uv sync --group docs
  • uv run --group docs sphinx-build -W -b html docs docs/_build/html
  • find docs/_build -name .doctrees -prune -exec rm -rf {} \; find docs/_build -name .buildinfo -exec rm {} \; touch docs/_build/html/.nojekyll
  • echo "${{ github.event.pull_request.number }}" > pr-number.txt if [ "${{ github.event.pull_request.head.repo.full_name }}" = "${{ github.repository }}" ]; then echo "true" > same-repo.txt else echo "false" > same-repo.txt fi
  • echo "${{ github.event.pull_request.number }}" > pr-number.txt echo "closed" > pr-action.txt
View raw YAML
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

# Step 1 of 2: Build docs on the PR and upload as an artifact.
# Step 2 (deploy) runs in docs-preview-deploy.yaml via workflow_run,
# which has write access to gh-pages regardless of PR origin.

name: Docs PR Preview

on:
  pull_request:
    branches: [main]
    types: [opened, reopened, synchronize, closed]
    paths:
      - "docs/**"
      - "README.md"
      - "pyproject.toml"
      - "uv.lock"
      - ".github/workflows/docs-preview-pr.yaml"
      - ".github/workflows/docs-preview-deploy.yaml"

concurrency:
  group: preview-${{ github.ref }}
  cancel-in-progress: true

permissions:
  contents: read

jobs:
  build:
    if: github.event.action != 'closed'
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - name: Checkout
        uses: actions/checkout@v6

      - name: Set up Python
        uses: actions/setup-python@v6
        with:
          python-version: "3.11"

      - name: Install uv
        uses: astral-sh/setup-uv@v7

      - name: Install doc dependencies
        run: uv sync --group docs

      - name: Build documentation
        run: uv run --group docs sphinx-build -W -b html docs docs/_build/html

      - name: Clean build artifacts
        run: |
          find docs/_build -name .doctrees -prune -exec rm -rf {} \;
          find docs/_build -name .buildinfo -exec rm {} \;
          touch docs/_build/html/.nojekyll

      - name: Upload preview artifact
        uses: actions/upload-artifact@v7
        with:
          name: docs-preview
          path: docs/_build/html/
          retention-days: 3

      - name: Save PR metadata
        run: |
          echo "${{ github.event.pull_request.number }}" > pr-number.txt
          if [ "${{ github.event.pull_request.head.repo.full_name }}" = "${{ github.repository }}" ]; then
            echo "true" > same-repo.txt
          else
            echo "false" > same-repo.txt
          fi

      - name: Upload PR metadata
        uses: actions/upload-artifact@v7
        with:
          name: docs-preview-metadata
          path: |
            pr-number.txt
            same-repo.txt
          retention-days: 3

  # On PR close, trigger the deploy workflow to clean up the preview.
  close:
    if: github.event.action == 'closed'
    runs-on: ubuntu-latest
    timeout-minutes: 2
    steps:
      - name: Save PR metadata
        run: |
          echo "${{ github.event.pull_request.number }}" > pr-number.txt
          echo "closed" > pr-action.txt

      - name: Upload PR metadata
        uses: actions/upload-artifact@v7
        with:
          name: docs-preview-metadata
          path: |
            pr-number.txt
            pr-action.txt
          retention-days: 3
e2e-brev perms .github/workflows/e2e-brev.yaml
Triggers
workflow_dispatch, workflow_call
Runs on
ubuntu-latest
Jobs
e2e-brev
Commands
  • PR_SHA=$(gh pr view ${{ inputs.pr_number }} --json headRefOid -q .headRefOid) CHECK_RUN_ID=$(gh api repos/${{ github.repository }}/check-runs \ -f name="Brev E2E (${{ inputs.test_suite }})" \ -f head_sha="$PR_SHA" \ -f status="in_progress" \ -f "output[title]=Running on ephemeral Brev instance" \ -f "output[summary]=Tests in progress..." \ --jq '.id') echo "CHECK_RUN_ID=$CHECK_RUN_ID" >> "$GITHUB_ENV" echo "PR_SHA=$PR_SHA" >> "$GITHUB_ENV"
  • # Pin to v0.6.310 — v0.6.322 removed --cpu flag and defaults to GPU instances curl -fsSL -o /tmp/brev.tar.gz "https://github.com/brevdev/brev-cli/releases/download/v0.6.310/brev-cli_0.6.310_linux_amd64.tar.gz" tar -xzf /tmp/brev.tar.gz -C /usr/local/bin brev chmod +x /usr/local/bin/brev
  • npm install --ignore-scripts
  • npx vitest run --project e2e-brev --reporter=verbose
  • CONCLUSION=${{ job.status == 'success' && 'success' || 'failure' }} gh api repos/${{ github.repository }}/check-runs/${{ env.CHECK_RUN_ID }} \ -X PATCH \ -f status="completed" \ -f conclusion="$CONCLUSION" \ -f "output[title]=Brev E2E (${{ inputs.test_suite }}): ${CONCLUSION}" \ -f "output[summary]=See workflow run for details."
  • if [ "${{ job.status }}" = "success" ]; then EMOJI="✅" STATUS="PASSED" else EMOJI="❌" STATUS="FAILED" fi INSTANCE="e2e-pr-${{ inputs.pr_number || github.run_id }}" BODY="${EMOJI} **Brev E2E** (${{ inputs.test_suite }}): **${STATUS}** on branch \`${{ inputs.branch }}\` — [See logs](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})" if [ "${{ inputs.keep_alive }}" = "true" ]; then BODY="${BODY} > **Instance \`${INSTANCE}\` is still running.** To SSH in: > \`\`\` > brev refresh && ssh ${INSTANCE} > \`\`\` > When done, delete it: \`brev delete ${INSTANCE}\`" fi gh pr comment ${{ inputs.pr_number }} --repo ${{ github.repository }} --body "$BODY"
View raw YAML
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

name: e2e-brev

on:
  workflow_dispatch:
    inputs:
      branch:
        description: "Branch to test"
        required: true
        default: "main"
      pr_number:
        description: "PR number (for status reporting, optional)"
        required: false
        default: ""
      test_suite:
        description: "Test suite to run"
        required: true
        default: "full"
        type: choice
        options:
          - full
          - credential-sanitization
          - all
      keep_alive:
        description: "Keep Brev instance alive after tests (for SSH debugging)"
        required: false
        type: boolean
        default: true
  workflow_call:
    inputs:
      branch:
        required: true
        type: string
      pr_number:
        required: false
        type: string
        default: ""
      test_suite:
        required: false
        type: string
        default: "full"
      keep_alive:
        required: false
        type: boolean
        default: true
    secrets:
      BREV_API_TOKEN:
        required: true
      NVIDIA_API_KEY:
        required: true

permissions:
  contents: read
  checks: write
  pull-requests: write

concurrency:
  group: e2e-brev-${{ inputs.pr_number || github.run_id }}
  cancel-in-progress: true

jobs:
  e2e-brev:
    if: github.repository == 'NVIDIA/NemoClaw'
    runs-on: ubuntu-latest
    timeout-minutes: 45
    steps:
      - name: Checkout target branch
        uses: actions/checkout@v6
        with:
          ref: ${{ inputs.branch }}

      - name: Create check run (pending)
        if: inputs.pr_number != ''
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          PR_SHA=$(gh pr view ${{ inputs.pr_number }} --json headRefOid -q .headRefOid)
          CHECK_RUN_ID=$(gh api repos/${{ github.repository }}/check-runs \
            -f name="Brev E2E (${{ inputs.test_suite }})" \
            -f head_sha="$PR_SHA" \
            -f status="in_progress" \
            -f "output[title]=Running on ephemeral Brev instance" \
            -f "output[summary]=Tests in progress..." \
            --jq '.id')
          echo "CHECK_RUN_ID=$CHECK_RUN_ID" >> "$GITHUB_ENV"
          echo "PR_SHA=$PR_SHA" >> "$GITHUB_ENV"

      - name: Setup Node.js
        uses: actions/setup-node@v6
        with:
          node-version: "22"
          cache: npm

      - name: Install Brev CLI
        run: |
          # Pin to v0.6.310 — v0.6.322 removed --cpu flag and defaults to GPU instances
          curl -fsSL -o /tmp/brev.tar.gz "https://github.com/brevdev/brev-cli/releases/download/v0.6.310/brev-cli_0.6.310_linux_amd64.tar.gz"
          tar -xzf /tmp/brev.tar.gz -C /usr/local/bin brev
          chmod +x /usr/local/bin/brev

      - name: Install dependencies
        run: npm install --ignore-scripts

      - name: Run ephemeral Brev E2E
        env:
          BREV_API_TOKEN: ${{ secrets.BREV_API_TOKEN }}
          NVIDIA_API_KEY: ${{ secrets.NVIDIA_API_KEY }}
          GITHUB_TOKEN: ${{ github.token }}
          INSTANCE_NAME: e2e-pr-${{ inputs.pr_number || github.run_id }}
          TEST_SUITE: ${{ inputs.test_suite }}
          KEEP_ALIVE: ${{ inputs.keep_alive }}
        run: npx vitest run --project e2e-brev --reporter=verbose

      - name: Update check run (completed)
        if: always() && inputs.pr_number != '' && env.CHECK_RUN_ID != ''
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          CONCLUSION=${{ job.status == 'success' && 'success' || 'failure' }}
          gh api repos/${{ github.repository }}/check-runs/${{ env.CHECK_RUN_ID }} \
            -X PATCH \
            -f status="completed" \
            -f conclusion="$CONCLUSION" \
            -f "output[title]=Brev E2E (${{ inputs.test_suite }}): ${CONCLUSION}" \
            -f "output[summary]=See workflow run for details."

      - name: Post PR comment
        if: always() && inputs.pr_number != ''
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          if [ "${{ job.status }}" = "success" ]; then
            EMOJI="✅"
            STATUS="PASSED"
          else
            EMOJI="❌"
            STATUS="FAILED"
          fi
          INSTANCE="e2e-pr-${{ inputs.pr_number || github.run_id }}"
          BODY="${EMOJI} **Brev E2E** (${{ inputs.test_suite }}): **${STATUS}** on branch \`${{ inputs.branch }}\` — [See logs](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})"
          if [ "${{ inputs.keep_alive }}" = "true" ]; then
            BODY="${BODY}

          > **Instance \`${INSTANCE}\` is still running.** To SSH in:
          > \`\`\`
          > brev refresh && ssh ${INSTANCE}
          > \`\`\`
          > When done, delete it: \`brev delete ${INSTANCE}\`"
          fi
          gh pr comment ${{ inputs.pr_number }} --repo ${{ github.repository }} --body "$BODY"

      - name: Upload test logs
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: e2e-brev-logs
          path: /tmp/brev-e2e-*.log
          if-no-files-found: ignore
nightly-e2e perms .github/workflows/nightly-e2e.yaml
Triggers
schedule, workflow_dispatch
Runs on
ubuntu-latest, ubuntu-latest, self-hosted, ubuntu-latest
Jobs
cloud-e2e, cloud-experimental-e2e, gpu-e2e, notify-on-failure
Commands
  • bash test/e2e/test-full-e2e.sh
  • bash test/e2e/test-e2e-cloud-experimental.sh
  • echo "=== GPU Info ===" nvidia-smi echo "" echo "=== VRAM ===" nvidia-smi --query-gpu=name,memory.total --format=csv,noheader echo "" echo "=== Docker ===" docker info --format '{{.ServerVersion}}'
  • bash test/e2e/test-gpu-e2e.sh
View raw YAML
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Nightly E2E tests:
#
#   cloud-e2e                Cloud inference (NVIDIA Endpoint API) on ubuntu-latest.
#   cloud-experimental-e2e   Experimental cloud inference test.
#   gpu-e2e                  Local Ollama inference on a GPU self-hosted runner.
#                            Controlled by the GPU_E2E_ENABLED repository variable.
#                            Set vars.GPU_E2E_ENABLED to "true" in repo settings to enable.
#   notify-on-failure        Auto-creates a GitHub issue when any E2E job fails.
#
# Runs directly on the runner (not inside Docker) because OpenShell bootstraps
# a K3s cluster inside a privileged Docker container — nesting would break networking.
#
# Requires NVIDIA_API_KEY repository secret (for cloud-e2e and cloud-experimental-e2e).
# Only runs on schedule and manual dispatch — never on PRs (secret protection).

name: nightly-e2e

on:
  schedule:
    - cron: "0 0 * * *"
  workflow_dispatch:

permissions:
  contents: read

concurrency:
  group: nightly-e2e
  cancel-in-progress: true

jobs:
  cloud-e2e:
    if: github.repository == 'NVIDIA/NemoClaw'
    runs-on: ubuntu-latest
    timeout-minutes: 45
    steps:
      - name: Checkout
        uses: actions/checkout@v6

      - name: Run cloud E2E test
        env:
          NVIDIA_API_KEY: ${{ secrets.NVIDIA_API_KEY }}
          NEMOCLAW_NON_INTERACTIVE: "1"
          NEMOCLAW_SANDBOX_NAME: "e2e-nightly"
          NEMOCLAW_RECREATE_SANDBOX: "1"
          GITHUB_TOKEN: ${{ github.token }}
        run: bash test/e2e/test-full-e2e.sh

      - name: Upload install log on failure
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: install-log
          path: /tmp/nemoclaw-e2e-install.log
          if-no-files-found: ignore

  cloud-experimental-e2e:
    if: github.repository == 'NVIDIA/NemoClaw'
    runs-on: ubuntu-latest
    environment: NVIDIA_API_KEY
    timeout-minutes: 45
    steps:
      - name: Checkout
        uses: actions/checkout@v6

      - name: Run cloud-experimental E2E test
        env:
          NVIDIA_API_KEY: ${{ secrets.NVIDIA_API_KEY }}
          GITHUB_TOKEN: ${{ github.token }}
          # Non-interactive install (expect-driven Phase 3 optional). Runner has no expect; Phase 5e TUI skips if expect is absent.
          RUN_E2E_CLOUD_EXPERIMENTAL_INTERACTIVE_INSTALL: "0"
          NEMOCLAW_NON_INTERACTIVE: "1"
          NEMOCLAW_RECREATE_SANDBOX: "1"
          NEMOCLAW_POLICY_MODE: "custom"
          NEMOCLAW_POLICY_PRESETS: "npm,pypi"
        run: bash test/e2e/test-e2e-cloud-experimental.sh

      - name: Upload install log on failure
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: install-log-cloud-experimental
          path: /tmp/nemoclaw-e2e-install.log
          if-no-files-found: ignore

  # ── GPU E2E (Ollama local inference) ──────────────────────────
  # Enable by setting repository variable GPU_E2E_ENABLED=true
  # (Settings → Secrets and variables → Actions → Variables)
  #
  # Runner labels: using 'self-hosted' for now. Refine to
  # [self-hosted, linux, x64, gpu] once NVIDIA runner labels are confirmed.
  gpu-e2e:
    if: github.repository == 'NVIDIA/NemoClaw' && vars.GPU_E2E_ENABLED == 'true'
    runs-on: self-hosted
    timeout-minutes: 60
    env:
      NEMOCLAW_NON_INTERACTIVE: "1"
      NEMOCLAW_SANDBOX_NAME: "e2e-gpu-ollama"
      NEMOCLAW_RECREATE_SANDBOX: "1"
      NEMOCLAW_PROVIDER: "ollama"
    steps:
      - name: Checkout
        uses: actions/checkout@v6

      - name: Verify GPU availability
        run: |
          echo "=== GPU Info ==="
          nvidia-smi
          echo ""
          echo "=== VRAM ==="
          nvidia-smi --query-gpu=name,memory.total --format=csv,noheader
          echo ""
          echo "=== Docker ==="
          docker info --format '{{.ServerVersion}}'

      - name: Run GPU E2E test (Ollama local inference)
        run: bash test/e2e/test-gpu-e2e.sh

      - name: Upload install log on failure
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: gpu-e2e-install-log
          path: /tmp/nemoclaw-gpu-e2e-install.log
          if-no-files-found: ignore

      - name: Upload test log on failure
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: gpu-e2e-test-log
          path: /tmp/nemoclaw-gpu-e2e-test.log
          if-no-files-found: ignore

  notify-on-failure:
    runs-on: ubuntu-latest
    needs: [cloud-e2e, cloud-experimental-e2e, gpu-e2e]
    if: ${{ always() && (needs.cloud-e2e.result == 'failure' || needs.cloud-experimental-e2e.result == 'failure' || needs.gpu-e2e.result == 'failure') }}
    permissions:
      issues: write
    steps:
      - name: Create or update failure issue
        uses: actions/github-script@v7
        with:
          script: |
            const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
            const title = 'Nightly E2E failed';

            const { data: existing } = await github.rest.issues.listForRepo({
              owner: context.repo.owner,
              repo: context.repo.repo,
              state: 'open',
              labels: 'CI/CD',
              per_page: 100,
            });
            const match = existing.find(i => !i.pull_request && i.title.startsWith(title));

            if (match) {
              await github.rest.issues.createComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: match.number,
                body: `Failed again on ${new Date().toISOString().split('T')[0]}.\n\n**Run:** ${runUrl}\n**Artifacts:** Check the run artifacts for install/test logs (artifact names vary by job).`,
              });
            } else {
              await github.rest.issues.create({
                owner: context.repo.owner,
                repo: context.repo.repo,
                title: `${title} — ${new Date().toISOString().split('T')[0]}`,
                body: `The nightly E2E pipeline failed.\n\n**Run:** ${runUrl}\n**Artifacts:** Check the run artifacts for install/test logs (artifact names vary by job).`,
                labels: ['bug', 'CI/CD'],
              });
            }
pr perms .github/workflows/pr.yaml
Triggers
pull_request
Runs on
ubuntu-latest, ubuntu-latest, ubuntu-latest, ubuntu-24.04-arm, ubuntu-latest, ubuntu-latest
Jobs
lint, test-unit, build-sandbox-images, build-sandbox-images-arm64, test-e2e-sandbox, test-e2e-gateway-isolation
Commands
  • HADOLINT_URL="https://github.com/hadolint/hadolint/releases/latest/download/hadolint-Linux-x86_64" curl -fsSL -o /usr/local/bin/hadolint "$HADOLINT_URL" EXPECTED=$(curl -fsSL "${HADOLINT_URL}.sha256" | awk '{print $1}') ACTUAL=$(sha256sum /usr/local/bin/hadolint | awk '{print $1}') [ "$EXPECTED" = "$ACTUAL" ] || { echo "::error::hadolint checksum mismatch"; exit 1; } chmod +x /usr/local/bin/hadolint
  • npm install --ignore-scripts cd nemoclaw && npm install
  • cd nemoclaw && npm run build
  • npx prek run --all-files --stage pre-push
  • npm install --ignore-scripts cd nemoclaw && npm install
  • cd nemoclaw && npm run build
  • npx vitest run --coverage
  • npx tsx scripts/check-coverage-ratchet.ts
View raw YAML
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

name: pr

on:
  pull_request:
    types: [opened, synchronize, reopened]

permissions:
  contents: read

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  lint:
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - name: Checkout
        uses: actions/checkout@v6

      - name: Setup Node.js
        uses: actions/setup-node@v6
        with:
          node-version: "22"
          cache: npm

      - name: Install hadolint
        run: |
          HADOLINT_URL="https://github.com/hadolint/hadolint/releases/latest/download/hadolint-Linux-x86_64"
          curl -fsSL -o /usr/local/bin/hadolint "$HADOLINT_URL"
          EXPECTED=$(curl -fsSL "${HADOLINT_URL}.sha256" | awk '{print $1}')
          ACTUAL=$(sha256sum /usr/local/bin/hadolint | awk '{print $1}')
          [ "$EXPECTED" = "$ACTUAL" ] || { echo "::error::hadolint checksum mismatch"; exit 1; }
          chmod +x /usr/local/bin/hadolint

      - name: Install dependencies
        run: |
          npm install --ignore-scripts
          cd nemoclaw && npm install

      - name: Build TypeScript plugin
        run: cd nemoclaw && npm run build

      - name: Run all hooks (pre-commit + pre-push)
        run: npx prek run --all-files --stage pre-push

  test-unit:
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - name: Checkout
        uses: actions/checkout@v6

      - name: Setup Node.js
        uses: actions/setup-node@v6
        with:
          node-version: "22"
          cache: npm

      - name: Install dependencies
        run: |
          npm install --ignore-scripts
          cd nemoclaw && npm install

      - name: Build TypeScript plugin
        run: cd nemoclaw && npm run build

      - name: Run all unit tests with coverage
        run: npx vitest run --coverage

      - name: Check coverage ratchet
        run: npx tsx scripts/check-coverage-ratchet.ts

  build-sandbox-images:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - name: Checkout
        uses: actions/checkout@v6

      - name: Pull base image from GHCR (fall back to local build)
        run: |
          if docker pull ghcr.io/nvidia/nemoclaw/sandbox-base:latest 2>/dev/null; then
            echo "BASE_IMAGE=ghcr.io/nvidia/nemoclaw/sandbox-base:latest" >> "$GITHUB_ENV"
          else
            echo "::warning::GHCR base image not available, building locally"
            docker build -f Dockerfile.base -t nemoclaw-sandbox-base-local .
            echo "BASE_IMAGE=nemoclaw-sandbox-base-local" >> "$GITHUB_ENV"
          fi

      - name: Build production image
        run: docker build --build-arg BASE_IMAGE=${{ env.BASE_IMAGE }} -t nemoclaw-production .

      - name: Build sandbox test image (fixtures layered on production)
        run: docker build -f test/Dockerfile.sandbox --build-arg BASE_IMAGE=nemoclaw-production -t nemoclaw-sandbox-test .

      - name: Save images to tarballs
        run: |
          docker save nemoclaw-sandbox-test | gzip > /tmp/sandbox-test-image.tar.gz
          docker save nemoclaw-production | gzip > /tmp/isolation-image.tar.gz

      - name: Upload sandbox test image
        uses: actions/upload-artifact@v4
        with:
          name: sandbox-test-image
          path: /tmp/sandbox-test-image.tar.gz
          retention-days: 1

      - name: Upload isolation image
        uses: actions/upload-artifact@v4
        with:
          name: isolation-image
          path: /tmp/isolation-image.tar.gz
          retention-days: 1

  build-sandbox-images-arm64:
    runs-on: ubuntu-24.04-arm
    timeout-minutes: 15
    steps:
      - name: Checkout
        uses: actions/checkout@v6

      - name: Pull base image from GHCR (fall back to local build)
        run: |
          if docker pull ghcr.io/nvidia/nemoclaw/sandbox-base:latest 2>/dev/null; then
            echo "BASE_IMAGE=ghcr.io/nvidia/nemoclaw/sandbox-base:latest" >> "$GITHUB_ENV"
          else
            echo "::warning::GHCR base image not available, building locally"
            docker build -f Dockerfile.base -t nemoclaw-sandbox-base-local .
            echo "BASE_IMAGE=nemoclaw-sandbox-base-local" >> "$GITHUB_ENV"
          fi

      - name: Build production image on arm64
        run: docker build --build-arg BASE_IMAGE=${{ env.BASE_IMAGE }} -t nemoclaw-production-arm64 .

      - name: Build sandbox test image on arm64
        run: docker build -f test/Dockerfile.sandbox --build-arg BASE_IMAGE=nemoclaw-production-arm64 -t nemoclaw-sandbox-test-arm64 .

  test-e2e-sandbox:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    needs: build-sandbox-images
    steps:
      - name: Checkout
        uses: actions/checkout@v6

      - name: Download image artifact
        uses: actions/download-artifact@v4
        with:
          name: sandbox-test-image
          path: /tmp

      - name: Load image
        run: gunzip -c /tmp/sandbox-test-image.tar.gz | docker load

      - name: Run sandbox E2E tests
        run: docker run --rm -v "${{ github.workspace }}/test:/opt/test" nemoclaw-sandbox-test /opt/test/e2e-test.sh

  test-e2e-gateway-isolation:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    needs: build-sandbox-images
    steps:
      - name: Checkout
        uses: actions/checkout@v6

      - name: Download image artifact
        uses: actions/download-artifact@v4
        with:
          name: isolation-image
          path: /tmp

      - name: Load image
        run: gunzip -c /tmp/isolation-image.tar.gz | docker load

      - name: Run gateway isolation E2E tests
        run: NEMOCLAW_TEST_IMAGE=nemoclaw-production bash test/e2e-gateway-isolation.sh
pr-limit perms .github/workflows/pr-limit.yaml
Triggers
pull_request_target
Runs on
ubuntu-latest
Jobs
check-pr-limit
Commands
  • # Core maintainers are exempt from the PR limit. EXEMPT="ericksoa kjw3 jacobtomlinson cv" for user in $EXEMPT; do if [ "$AUTHOR" = "$user" ]; then echo "Author $AUTHOR is a core maintainer — exempt from PR limit" exit 0 fi done OPEN_COUNT=$(gh pr list --repo "$REPO" --author "$AUTHOR" --state open --json number --jq 'length') echo "Author $AUTHOR has $OPEN_COUNT open PR(s)" if [ "$OPEN_COUNT" -gt 10 ]; then gh pr comment "$PR_NUMBER" --repo "$REPO" --body \ "This repository limits contributors to 10 open pull requests. Please close or merge existing PRs before opening new ones." gh pr close "$PR_NUMBER" --repo "$REPO" echo "::error::PR closed — author $AUTHOR exceeds the 10 open PR limit" exit 1 fi
View raw YAML
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

name: pr-limit

# pull_request_target runs in the base repo context, giving the token write
# access even for fork PRs. This is safe here because this workflow never
# checks out or executes code from the PR — it only counts open PRs and
# closes excess ones. Do NOT add a checkout step or run PR-sourced code
# in this workflow to prevent injection attacks.
on:
  pull_request_target:
    types: [opened, reopened]

permissions:
  pull-requests: write

jobs:
  check-pr-limit:
    runs-on: ubuntu-latest
    timeout-minutes: 5
    steps:
      - name: Check open PR count for author
        env:
          GH_TOKEN: ${{ github.token }}
          AUTHOR: ${{ github.event.pull_request.user.login }}
          PR_NUMBER: ${{ github.event.pull_request.number }}
          REPO: ${{ github.repository }}
        run: |
          # Core maintainers are exempt from the PR limit.
          EXEMPT="ericksoa kjw3 jacobtomlinson cv"
          for user in $EXEMPT; do
            if [ "$AUTHOR" = "$user" ]; then
              echo "Author $AUTHOR is a core maintainer — exempt from PR limit"
              exit 0
            fi
          done

          OPEN_COUNT=$(gh pr list --repo "$REPO" --author "$AUTHOR" --state open --json number --jq 'length')

          echo "Author $AUTHOR has $OPEN_COUNT open PR(s)"

          if [ "$OPEN_COUNT" -gt 10 ]; then
            gh pr comment "$PR_NUMBER" --repo "$REPO" --body \
              "This repository limits contributors to 10 open pull requests. Please close or merge existing PRs before opening new ones."
            gh pr close "$PR_NUMBER" --repo "$REPO"
            echo "::error::PR closed — author $AUTHOR exceeds the 10 open PR limit"
            exit 1
          fi