From f932f35c2f23376c27bb2860cea9d6aaf19aaf03 Mon Sep 17 00:00:00 2001 From: jichuanh Date: Sat, 4 Jul 2026 11:38:39 +0000 Subject: [PATCH 01/13] Add kitless Newton Docker image Prepare an Ubuntu-based image for headless Newton training without the Isaac Sim runtime. Document local build and validation while keeping the image independent of existing workflows. --- .dockerignore | 1 + docker/Dockerfile.newton | 95 +++++++++++++++++++ docker/test/test_dockerfile_nonroot.py | 17 +++- docs/source/deployment/docker.rst | 32 ++++++- .../newton-kitless-container.minor.rst | 4 + 5 files changed, 146 insertions(+), 3 deletions(-) create mode 100644 docker/Dockerfile.newton create mode 100644 source/isaaclab/changelog.d/newton-kitless-container.minor.rst diff --git a/.dockerignore b/.dockerignore index b718bd108a8a..1c382038d7f3 100644 --- a/.dockerignore +++ b/.dockerignore @@ -28,4 +28,5 @@ _isaac_sim docker/.isaac-lab-docker-history # ignore uv environment env_isaaclab +.venv/ tools/wheel_builder/build/ diff --git a/docker/Dockerfile.newton b/docker/Dockerfile.newton new file mode 100644 index 000000000000..8862e4d4e95a --- /dev/null +++ b/docker/Dockerfile.newton @@ -0,0 +1,95 @@ +# Copyright (c) 2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +# Kitless Newton image for GPU-accelerated headless training without Isaac Sim. +ARG BASE_IMAGE_ARG=ubuntu:24.04 + +FROM ${BASE_IMAGE_ARG} AS builder + +SHELL ["/bin/bash", "-o", "pipefail", "-c"] + +ARG ISAACLAB_PATH_ARG=/workspace/isaaclab +ENV ISAACLAB_PATH=${ISAACLAB_PATH_ARG} +ENV DEBIAN_FRONTEND=noninteractive +ENV LANG=C.UTF-8 +ENV VIRTUAL_ENV=${ISAACLAB_PATH_ARG}/.venv +ENV PATH=${ISAACLAB_PATH_ARG}/.venv/bin:/usr/local/bin:${PATH} +ENV UV_NO_CACHE=1 + +# Keep this package set aligned with the documented Ubuntu installation path. +# Native build tools remain confined to this stage. +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + build-essential \ + ca-certificates \ + cmake \ + curl \ + git \ + libglib2.0-0 \ + python3 \ + python3-dev \ + && rm -rf /var/lib/apt/lists/* \ + && curl -LsSf https://astral.sh/uv/install.sh | UV_INSTALL_DIR=/usr/local/bin sh + +WORKDIR ${ISAACLAB_PATH} + +# Copy the install inputs before the rest of the runtime tree so dependency +# installation remains cached when only scripts or application code changes. +COPY isaaclab.sh environment.yml pyproject.toml VERSION LICENSE LICENSE-mimic README.md ./ +COPY source/ source/ +COPY tools/ tools/ + +RUN chmod +x isaaclab.sh \ + && uv venv --python /usr/bin/python3 --seed "${VIRTUAL_ENV}" \ + && ./isaaclab.sh --install 'newton,rl[rsl-rl]' \ + && python -c "import importlib.metadata as m; assert all(d.metadata['Name'].lower() != 'isaacsim' for d in m.distributions())" \ + && test ! -e "${ISAACLAB_PATH}/_isaac_sim" \ + && uv cache clean + +COPY apps/ apps/ +COPY scripts/ scripts/ +COPY docs/licenses/ docs/licenses/ + + +FROM ${BASE_IMAGE_ARG} AS runtime + +SHELL ["/bin/bash", "-o", "pipefail", "-c"] + +ARG ISAACLAB_PATH_ARG=/workspace/isaaclab +ENV ISAACLAB_PATH=${ISAACLAB_PATH_ARG} +ENV DEBIAN_FRONTEND=noninteractive +ENV LANG=C.UTF-8 +ENV HOME=/home/isaaclab +ENV VIRTUAL_ENV=${ISAACLAB_PATH_ARG}/.venv +ENV PATH=${ISAACLAB_PATH_ARG}/.venv/bin:/usr/local/bin:${PATH} +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 +ENV NVIDIA_VISIBLE_DEVICES=all +ENV NVIDIA_DRIVER_CAPABILITIES=compute,utility + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + git \ + libglib2.0-0 \ + libpython3.12 \ + python3 \ + && rm -rf /var/lib/apt/lists/* \ + && if getent passwd ubuntu >/dev/null; then userdel -r ubuntu; fi \ + && if getent group ubuntu >/dev/null; then groupdel ubuntu; fi \ + && groupadd --gid 1000 isaaclab \ + && useradd --uid 1000 --gid 1000 --create-home --shell /bin/bash isaaclab + +COPY --from=builder /usr/local/bin/uv /usr/local/bin/uv +COPY --from=builder /usr/local/bin/uvx /usr/local/bin/uvx +COPY --from=builder --chown=isaaclab:isaaclab ${ISAACLAB_PATH} ${ISAACLAB_PATH} + +RUN mkdir -p ${ISAACLAB_PATH}/logs ${HOME}/.cache/uv ${HOME}/.cache/warp \ + && chown -R isaaclab:isaaclab ${ISAACLAB_PATH}/logs ${HOME} + +USER isaaclab +WORKDIR ${ISAACLAB_PATH} + +CMD ["bash"] diff --git a/docker/test/test_dockerfile_nonroot.py b/docker/test/test_dockerfile_nonroot.py index 52959e6ffa79..5e6a0e12c8fa 100644 --- a/docker/test/test_dockerfile_nonroot.py +++ b/docker/test/test_dockerfile_nonroot.py @@ -35,12 +35,18 @@ def _load_module(name: str, path: Path): "Dockerfile.base": "isaaclab", "Dockerfile.curobo": "isaaclab", "Dockerfile.installci": "isaaclab", + "Dockerfile.newton": "isaaclab", "Dockerfile.ros2": "isaaclab", } # Dockerfiles that are expected to *create* the non-root runtime user # (i.e. contain groupadd/useradd/USER isaaclab). -DOCKERFILES_CREATING_RUNTIME_USER = {"Dockerfile.base", "Dockerfile.curobo", "Dockerfile.installci"} +DOCKERFILES_CREATING_RUNTIME_USER = { + "Dockerfile.base", + "Dockerfile.curobo", + "Dockerfile.installci", + "Dockerfile.newton", +} USER_DIRECTIVE_RE = re.compile(r"^USER\s+(\S+)\s*$") @@ -103,6 +109,15 @@ def test_ros2_dockerfile_restores_non_root_runtime_user(): assert _user_directives(dockerfile_text) == ["root", "isaaclab"] +def test_newton_dockerfile_excludes_isaac_sim(): + """The kitless image installs Newton without an Isaac Sim runtime.""" + dockerfile_text = (DOCKER_DIR / "Dockerfile.newton").read_text(encoding="utf-8") + + assert "--install 'newton,rl[rsl-rl]'" in dockerfile_text + assert "all(d.metadata['Name'].lower() != 'isaacsim'" in dockerfile_text + assert 'test ! -e "${ISAACLAB_PATH}/_isaac_sim"' in dockerfile_text + + # --------------------------------------------------------------------------- # # Volume mount-point writability # diff --git a/docs/source/deployment/docker.rst b/docs/source/deployment/docker.rst index c5eadb3c5416..ac612c013f21 100644 --- a/docs/source/deployment/docker.rst +++ b/docs/source/deployment/docker.rst @@ -6,8 +6,9 @@ Docker Guide .. caution:: - Due to the dependency on Isaac Sim docker image, by running this container you are implicitly - agreeing to the `NVIDIA Software License Agreement`_. If you do not agree to the EULA, do not run this container. + The standard Isaac Lab container depends on the Isaac Sim Docker image. By running that container, you are + implicitly agreeing to the `NVIDIA Software License Agreement`_. The kitless Newton container described below + does not contain Isaac Sim. Setup Instructions ------------------ @@ -45,6 +46,7 @@ needed to run Isaac Lab inside a Docker container. A subset of these are summari * **Dockerfile.base**: Defines the base Isaac Lab image by overlaying its dependencies onto the Isaac Sim Docker image. Dockerfiles which end with something else, (i.e. ``Dockerfile.ros2``) build an `image extension <#isaac-lab-image-extensions>`_. +* **Dockerfile.newton**: Defines a kitless image for headless Newton training without Isaac Sim dependencies. * **docker-compose.yaml**: Creates mounts to allow direct editing of Isaac Lab code from the host machine that runs the container. It also creates several named volumes such as ``isaac-cache-kit`` to store frequently reused resources compiled by Isaac Sim, such as shaders, and to retain logs, data, and documents. @@ -300,6 +302,32 @@ The container defaults to ``FastRTPS``, but ``CylconeDDS`` is also supported. Ea :language: bash +Building the Kitless Newton Container +------------------------------------- + +The kitless Newton container uses Ubuntu 24.04 and a Python 3.12 virtual environment instead of the Isaac Sim base +image. It includes the Newton backend and RSL-RL for GPU-accelerated headless training. Build it from the repository +root: + +.. code:: bash + + docker build --file docker/Dockerfile.newton --tag isaac-lab-newton . + +Run a short Cartpole training job to verify the image and NVIDIA Container Toolkit setup: + +.. code:: bash + + docker run --rm --gpus all --network host isaac-lab-newton \ + train --rl_library rsl_rl \ + --task Isaac-Cartpole-Direct \ + --num_envs 16 \ + presets=newton_mjwarp \ + --max_iterations 5 + +The image runs as the non-root ``isaaclab`` user with uid/gid 1000. It is intentionally separate from the Compose +profiles, which configure Isaac Sim services and cache volumes. + + Running Pre-Built Isaac Lab Container ------------------------------------- diff --git a/source/isaaclab/changelog.d/newton-kitless-container.minor.rst b/source/isaaclab/changelog.d/newton-kitless-container.minor.rst new file mode 100644 index 000000000000..23a7461b3975 --- /dev/null +++ b/source/isaaclab/changelog.d/newton-kitless-container.minor.rst @@ -0,0 +1,4 @@ +Added +^^^^^ + +* Added a kitless Newton container for headless training without Isaac Sim dependencies. From 2c20ab722f3a9c3863aa77ffdeefe6e4acfcd4b9 Mon Sep 17 00:00:00 2001 From: jichuanh Date: Wed, 8 Jul 2026 11:48:25 +0000 Subject: [PATCH 02/13] Add CI build and smoke test for Newton kitless image Build docker/Dockerfile.newton from the public ubuntu:24.04 base and run the documented cartpole training probe plus a non-root runtime check, mirroring the folded verification steps used for the base and cuRobo images. Also pin the builder venv to the system Python 3.12 so it cannot drift from the runtime stage's libpython3.12, and fix the copyright header year range. --- .github/workflows/build.yaml | 68 ++++++++++++++++++++++++++++++++++++ docker/Dockerfile.newton | 7 ++-- 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 1bef75f6b010..a534bd315f06 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -273,6 +273,74 @@ jobs: dockerfile-path: docker/Dockerfile.curobo cache-tag: cache-curobo + build-newton: + name: Build Newton Kitless Docker Image + runs-on: [self-hosted, gpu] + timeout-minutes: 120 + needs: [changes] + if: >- + github.event_name != 'push' && + needs.changes.outputs.run_docker_tests == 'true' + steps: + - name: Checkout Code + uses: actions/checkout@v6 + with: + fetch-depth: 1 + lfs: true + + # Dockerfile.newton builds from the public ubuntu:24.04 image, so it needs + # no NGC login or Isaac Sim build args and a plain local build suffices. + # The kitless guarantee is asserted inside the Dockerfile itself (no + # isaacsim distribution, no _isaac_sim link), so a green build is the proof. + - name: Build Newton kitless image + shell: bash + run: | + set -euo pipefail + docker build --file docker/Dockerfile.newton --tag "isaac-lab-newton:${{ github.sha }}" . + + # Mirrors the verification command documented in + # docs/source/deployment/docker.rst and the canonical cartpole training + # probe used by the installation tests. + - name: Run training smoke test + shell: bash + run: | + set -euo pipefail + log="$(mktemp)" + docker run --rm --gpus all --network host "isaac-lab-newton:${{ github.sha }}" \ + train --rl_library rsl_rl \ + --task Isaac-Cartpole-Direct \ + --num_envs 16 \ + presets=newton_mjwarp \ + --max_iterations 5 2>&1 | tee "$log" + if grep -q "Traceback (most recent call last):" "$log"; then + echo "::error::Training smoke test raised a traceback." + exit 1 + fi + if ! grep -q "Training time:" "$log"; then + echo "::error::Training smoke test did not report 'Training time:'." + exit 1 + fi + + - name: Verify Newton runtime user is non-root + shell: bash + run: | + set -euo pipefail + runtime_identity="$(docker run --rm --entrypoint bash "isaac-lab-newton:${{ github.sha }}" \ + -lc 'printf "%s %s %s\n" "$(id -u)" "$(id -g)" "$(id -un 2>/dev/null || true)"')" + read -r runtime_uid runtime_gid runtime_user <<< "${runtime_identity}" + echo "Newton runtime identity: uid=${runtime_uid} gid=${runtime_gid} user=${runtime_user}" + if [ "${runtime_uid}" = "0" ]; then + echo "::error::Newton Docker image must not run as root by default." + exit 1 + fi + + # The image is built locally (not pushed to ECR), so drop it to keep the + # self-hosted runner's disk from accumulating one image per CI run. + - name: Remove local image + if: always() + shell: bash + run: docker image rm -f "isaac-lab-newton:${{ github.sha }}" || true + #endregion #region test jobs diff --git a/docker/Dockerfile.newton b/docker/Dockerfile.newton index 8862e4d4e95a..51f4961b5d37 100644 --- a/docker/Dockerfile.newton +++ b/docker/Dockerfile.newton @@ -1,4 +1,4 @@ -# Copyright (c) 2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). # All rights reserved. # # SPDX-License-Identifier: BSD-3-Clause @@ -41,8 +41,11 @@ COPY isaaclab.sh environment.yml pyproject.toml VERSION LICENSE LICENSE-mimic RE COPY source/ source/ COPY tools/ tools/ +# The venv pins the system Python 3.12 (the version documented in the install +# docs) so the interpreter cannot silently drift from the runtime stage's +# libpython3.12 if the base image's default python3 ever changes. RUN chmod +x isaaclab.sh \ - && uv venv --python /usr/bin/python3 --seed "${VIRTUAL_ENV}" \ + && uv venv --python /usr/bin/python3.12 --seed "${VIRTUAL_ENV}" \ && ./isaaclab.sh --install 'newton,rl[rsl-rl]' \ && python -c "import importlib.metadata as m; assert all(d.metadata['Name'].lower() != 'isaacsim' for d in m.distributions())" \ && test ! -e "${ISAACLAB_PATH}/_isaac_sim" \ From 2a664ffa35f71268909690f92179fb9d268f8a03 Mon Sep 17 00:00:00 2001 From: jichuanh Date: Wed, 8 Jul 2026 11:48:25 +0000 Subject: [PATCH 03/13] TEMP: disable docker and install CI while iterating Force run_docker_tests=false in build.yaml's changes job and run_install_tests=false in install-ci.yml so all gated jobs skip via their existing if-gates, and run build-newton unconditionally on PRs. Must be reverted before final review. --- .github/workflows/build.yaml | 10 ++++++---- .github/workflows/install-ci.yml | 4 +++- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index a534bd315f06..97c9915e1829 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -79,7 +79,9 @@ jobs: name: Detect Changes runs-on: ubuntu-latest outputs: - run_docker_tests: ${{ steps.detect.outputs.run_docker_tests }} + # TEMP (revert before final review): force run_docker_tests=false while + # iterating on the build-newton job for PR #6355. + run_docker_tests: 'false' steps: - id: detect env: @@ -278,9 +280,9 @@ jobs: runs-on: [self-hosted, gpu] timeout-minutes: 120 needs: [changes] - if: >- - github.event_name != 'push' && - needs.changes.outputs.run_docker_tests == 'true' + # TEMP (revert before final review): run unconditionally on PRs while the + # changes gate above is forced false during iteration. + if: github.event_name != 'push' steps: - name: Checkout Code uses: actions/checkout@v6 diff --git a/.github/workflows/install-ci.yml b/.github/workflows/install-ci.yml index 226222d425bd..070d40d0e8fa 100644 --- a/.github/workflows/install-ci.yml +++ b/.github/workflows/install-ci.yml @@ -36,7 +36,9 @@ jobs: name: Detect Changes runs-on: ubuntu-latest outputs: - run_install_tests: ${{ steps.detect.outputs.run_install_tests }} + # TEMP (revert before final review): force run_install_tests=false while + # iterating on the build-newton job for PR #6355. + run_install_tests: 'false' steps: - id: detect env: From d50de4dc01c37028db6b0b2d0ff5222d4d66bbba Mon Sep 17 00:00:00 2001 From: jichuanh Date: Wed, 8 Jul 2026 12:22:38 +0000 Subject: [PATCH 04/13] Invoke smoke training via the isaaclab console script The bare train/play console scripts were removed from the isaaclab package by the central pyproject consolidation (#6009); the only remaining entry point is isaaclab. CI builds the PR merge commit, so the kitless image no longer contains .venv/bin/train and 'docker run ... train' fails with 'executable file not found'. Switch the docs verification command and the CI smoke test to 'isaaclab train', matching the uv workflow docs. --- .github/workflows/build.yaml | 2 +- docs/source/deployment/docker.rst | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index a6671c5c914f..1c503de25a19 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -309,7 +309,7 @@ jobs: set -euo pipefail log="$(mktemp)" docker run --rm --gpus all --network host "isaac-lab-newton:${{ github.sha }}" \ - train --rl_library rsl_rl \ + isaaclab train --rl_library rsl_rl \ --task Isaac-Cartpole-Direct \ --num_envs 16 \ presets=newton_mjwarp \ diff --git a/docs/source/deployment/docker.rst b/docs/source/deployment/docker.rst index ac612c013f21..6588cbf74d7f 100644 --- a/docs/source/deployment/docker.rst +++ b/docs/source/deployment/docker.rst @@ -318,7 +318,7 @@ Run a short Cartpole training job to verify the image and NVIDIA Container Toolk .. code:: bash docker run --rm --gpus all --network host isaac-lab-newton \ - train --rl_library rsl_rl \ + isaaclab train --rl_library rsl_rl \ --task Isaac-Cartpole-Direct \ --num_envs 16 \ presets=newton_mjwarp \ From 933646ff08b4cfb281a58bc7a8470ba5d266ffb4 Mon Sep 17 00:00:00 2001 From: jichuanh Date: Wed, 8 Jul 2026 12:59:01 +0000 Subject: [PATCH 05/13] Revert "TEMP: disable docker and install CI while iterating" This reverts commit 2a664ffa35f71268909690f92179fb9d268f8a03. --- .github/workflows/build.yaml | 10 ++++------ .github/workflows/install-ci.yml | 4 +--- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 1c503de25a19..3fda4de5a471 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -79,9 +79,7 @@ jobs: name: Detect Changes runs-on: ubuntu-latest outputs: - # TEMP (revert before final review): force run_docker_tests=false while - # iterating on the build-newton job for PR #6355. - run_docker_tests: 'false' + run_docker_tests: ${{ steps.detect.outputs.run_docker_tests }} steps: - id: detect env: @@ -280,9 +278,9 @@ jobs: runs-on: [self-hosted, gpu] timeout-minutes: 120 needs: [changes] - # TEMP (revert before final review): run unconditionally on PRs while the - # changes gate above is forced false during iteration. - if: github.event_name != 'push' + if: >- + github.event_name != 'push' && + needs.changes.outputs.run_docker_tests == 'true' steps: - name: Checkout Code uses: actions/checkout@v6 diff --git a/.github/workflows/install-ci.yml b/.github/workflows/install-ci.yml index 070d40d0e8fa..226222d425bd 100644 --- a/.github/workflows/install-ci.yml +++ b/.github/workflows/install-ci.yml @@ -36,9 +36,7 @@ jobs: name: Detect Changes runs-on: ubuntu-latest outputs: - # TEMP (revert before final review): force run_install_tests=false while - # iterating on the build-newton job for PR #6355. - run_install_tests: 'false' + run_install_tests: ${{ steps.detect.outputs.run_install_tests }} steps: - id: detect env: From 91e1d1fa4cf99fa01203f03ba4329c2d3721d05c Mon Sep 17 00:00:00 2001 From: jichuanh Date: Thu, 9 Jul 2026 22:39:55 +0000 Subject: [PATCH 06/13] Remove unused install inputs from Dockerfile.newton The kitless image copied environment.yml and tools/ into the dependency-install layer, although the install path reads neither: version pins come from [tool.isaaclab.versions] in the root pyproject.toml, and environment.yml is only read by the conda envs command. Both invalidated the cached install layer on unrelated edits. Also drop the uv cache clean step, a no-op under UV_NO_CACHE=1, and document why apps/ must stay: isaaclab parses apps/isaaclab.python.kit at import time to resolve the Nucleus asset root. --- docker/Dockerfile.newton | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docker/Dockerfile.newton b/docker/Dockerfile.newton index 51f4961b5d37..fc3b98332ad8 100644 --- a/docker/Dockerfile.newton +++ b/docker/Dockerfile.newton @@ -37,9 +37,8 @@ WORKDIR ${ISAACLAB_PATH} # Copy the install inputs before the rest of the runtime tree so dependency # installation remains cached when only scripts or application code changes. -COPY isaaclab.sh environment.yml pyproject.toml VERSION LICENSE LICENSE-mimic README.md ./ +COPY isaaclab.sh pyproject.toml VERSION LICENSE LICENSE-mimic README.md ./ COPY source/ source/ -COPY tools/ tools/ # The venv pins the system Python 3.12 (the version documented in the install # docs) so the interpreter cannot silently drift from the runtime stage's @@ -48,9 +47,10 @@ RUN chmod +x isaaclab.sh \ && uv venv --python /usr/bin/python3.12 --seed "${VIRTUAL_ENV}" \ && ./isaaclab.sh --install 'newton,rl[rsl-rl]' \ && python -c "import importlib.metadata as m; assert all(d.metadata['Name'].lower() != 'isaacsim' for d in m.distributions())" \ - && test ! -e "${ISAACLAB_PATH}/_isaac_sim" \ - && uv cache clean + && test ! -e "${ISAACLAB_PATH}/_isaac_sim" +# isaaclab.utils.assets parses apps/isaaclab.python.kit at import time to +# resolve the Nucleus asset root, so apps/ is required even without Kit. COPY apps/ apps/ COPY scripts/ scripts/ COPY docs/licenses/ docs/licenses/ From 3be7eb2d1736c7941a38916eb0728aca14965978 Mon Sep 17 00:00:00 2001 From: jichuanh Date: Fri, 10 Jul 2026 00:05:14 +0000 Subject: [PATCH 07/13] Use ECR-cached build for the Newton kitless CI image Rebuilding the Newton image from scratch on every triggered run re-downloads the full wheel set and occupies a GPU runner for the whole build. Reuse the ecr-build-push-pull action like the base and curobo images: an existing per-commit or deps-hash tag in ECR skips the build entirely, and full builds reuse the registry layer cache. The training smoke now mounts source/, scripts/ and apps/ from the checkout so a deps-cached image still runs the PR's code; this is sound because the isaaclab packages are installed editable from source/. --- .github/workflows/build.yaml | 61 ++++++++++++++++++++++++++++-------- 1 file changed, 48 insertions(+), 13 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 3fda4de5a471..58db757216d4 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -277,7 +277,7 @@ jobs: name: Build Newton Kitless Docker Image runs-on: [self-hosted, gpu] timeout-minutes: 120 - needs: [changes] + needs: [changes, config] if: >- github.event_name != 'push' && needs.changes.outputs.run_docker_tests == 'true' @@ -288,25 +288,56 @@ jobs: fetch-depth: 1 lfs: true - # Dockerfile.newton builds from the public ubuntu:24.04 image, so it needs - # no NGC login or Isaac Sim build args and a plain local build suffices. - # The kitless guarantee is asserted inside the Dockerfile itself (no - # isaacsim distribution, no _isaac_sim link), so a green build is the proof. - - name: Build Newton kitless image + # Same ECR-cached setup as the other image builds: an existing per-commit + # or deps- tag in ECR skips the build entirely, and full builds + # reuse the registry layer cache. Dockerfile.newton builds from the public + # ubuntu:24.04 image, so the (historically named) isaacsim-base-image / + # isaacsim-version inputs only pin the base image whose digest feeds the + # deps hash; the ISAACSIM_* build-args the action passes are not declared + # by this Dockerfile and are ignored. The kitless guarantee stays asserted + # inside the Dockerfile itself (no isaacsim distribution, no _isaac_sim + # link), so any path that produces the image has already proven it. + - name: Build and push to ECR + uses: ./.github/actions/ecr-build-push-pull + with: + image-tag: ${{ needs.config.outputs.ci_image_tag }}-newton + isaacsim-base-image: ubuntu + isaacsim-version: "24.04" + dockerfile-path: docker/Dockerfile.newton + cache-tag: cache-newton + + # On a deps-cache hit the action only aliases the per-commit tag in the + # registry without pulling it, so fetch it before the docker run steps. + - name: Ensure image is available locally shell: bash run: | set -euo pipefail - docker build --file docker/Dockerfile.newton --tag "isaac-lab-newton:${{ github.sha }}" . + IMAGE_TAG="${{ needs.config.outputs.ci_image_tag }}-newton" + if ! docker image inspect "${IMAGE_TAG}" >/dev/null 2>&1; then + if [ -z "${ECR_IMAGE:-}" ]; then + echo "::error::Image ${IMAGE_TAG} is not available locally and no ECR image is configured." + exit 1 + fi + docker pull "${ECR_IMAGE}" + docker tag "${ECR_IMAGE}" "${IMAGE_TAG}" + fi # Mirrors the verification command documented in # docs/source/deployment/docker.rst and the canonical cartpole training - # probe used by the installation tests. + # probe used by the installation tests. source/, scripts/ and apps/ are + # mounted from the checkout so a deps-cached image still runs this PR's + # code: the isaaclab packages are installed editable from source/, so the + # baked venv resolves imports through the mounted tree. - name: Run training smoke test shell: bash run: | set -euo pipefail log="$(mktemp)" - docker run --rm --gpus all --network host "isaac-lab-newton:${{ github.sha }}" \ + docker run --rm --gpus all --network host \ + -v "$PWD/source":/workspace/isaaclab/source \ + -v "$PWD/scripts":/workspace/isaaclab/scripts \ + -v "$PWD/apps":/workspace/isaaclab/apps \ + "${{ needs.config.outputs.ci_image_tag }}-newton" \ isaaclab train --rl_library rsl_rl \ --task Isaac-Cartpole-Direct \ --num_envs 16 \ @@ -325,7 +356,7 @@ jobs: shell: bash run: | set -euo pipefail - runtime_identity="$(docker run --rm --entrypoint bash "isaac-lab-newton:${{ github.sha }}" \ + runtime_identity="$(docker run --rm --entrypoint bash "${{ needs.config.outputs.ci_image_tag }}-newton" \ -lc 'printf "%s %s %s\n" "$(id -u)" "$(id -g)" "$(id -un 2>/dev/null || true)"')" read -r runtime_uid runtime_gid runtime_user <<< "${runtime_identity}" echo "Newton runtime identity: uid=${runtime_uid} gid=${runtime_gid} user=${runtime_user}" @@ -334,12 +365,16 @@ jobs: exit 1 fi - # The image is built locally (not pushed to ECR), so drop it to keep the - # self-hosted runner's disk from accumulating one image per CI run. + # Keep the self-hosted runner's disk lean: drop the local tags after the + # run; the layers stay cached in ECR for the next build. - name: Remove local image if: always() shell: bash - run: docker image rm -f "isaac-lab-newton:${{ github.sha }}" || true + run: | + docker image rm -f "${{ needs.config.outputs.ci_image_tag }}-newton" || true + if [ -n "${ECR_IMAGE:-}" ]; then + docker image rm -f "${ECR_IMAGE}" || true + fi #endregion From 64fd909a379e5bf8d1477499b0104ab4b9e9fc5d Mon Sep 17 00:00:00 2001 From: jichuanh Date: Fri, 10 Jul 2026 00:33:24 +0000 Subject: [PATCH 08/13] Gate the Newton docker job on install inputs only The Newton kitless job validates the containerized install/build path; library code is covered by the test matrix and the native install CI. Give it a dedicated run_newton_tests gate that fires only on install inputs (Dockerfile.newton, isaaclab.sh, root and per-package pyproject.toml, the install CLI implementation, and the job's own wiring), aligned with the deps-hash inputs of the ECR cache action, so unrelated source PRs no longer occupy a GPU runner. Also add pyproject.toml and isaaclab.sh to the shared docker-test patterns so dependency pin bumps trigger the docker jobs at all. Dependency drift with no repo change is accepted to surface on the next install-input PR rather than on a schedule. --- .github/workflows/build.yaml | 42 ++++++++++++++++++++++++++++-------- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 58db757216d4..736de2ee69d1 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -80,6 +80,7 @@ jobs: runs-on: ubuntu-latest outputs: run_docker_tests: ${{ steps.detect.outputs.run_docker_tests }} + run_newton_tests: ${{ steps.detect.outputs.run_newton_tests }} steps: - id: detect env: @@ -102,11 +103,28 @@ jobs: $'^tools/\tBuild tooling' $'^apps/\tStandalone apps' $'^scripts/\tStandalone scripts' + $'^pyproject\\.toml$\tRoot dependency manifest' + $'^isaaclab\\.sh$\tInstall CLI wrapper' $'^\\.github/workflows/build\\.yaml$\tThis workflow file' $'^\\.github/workflows/config\\.yaml$\tBase image config' $'^\\.github/actions/\tCI actions' $'^\\.github/test-subsets/\tCI test subset config' ) + # The Newton kitless job is an install/build test, not a library-code + # test (the test matrix covers code), so it gates only on the install + # inputs — the same set that feeds the deps-hash in the + # ecr-build-push-pull action — plus its own wiring. Source-level + # kitless regressions are accepted to surface on the next + # install-input change rather than on every source PR. + newton_patterns=( + $'^docker/Dockerfile\\.newton$\tNewton image definition' + $'^isaaclab\\.sh$\tInstall CLI wrapper' + $'^pyproject\\.toml$\tRoot dependency manifest' + $'^source/[^/]+/pyproject\\.toml$\tPackage manifests' + $'^source/isaaclab/isaaclab/cli/\tInstall CLI implementation' + $'^\\.github/workflows/build\\.yaml$\tThis workflow file' + $'^\\.github/actions/ecr-build-push-pull/\tECR build-cache action' + ) if [ "$EVENT_NAME" = "push" ]; then triggered_jobs="Docker base build job + rendering-correctness + rendering-correctness-kitless" else @@ -134,7 +152,8 @@ jobs: any_match() { local files="$1" entry regex - for entry in "${patterns[@]}"; do + local -n _pats="$2" + for entry in "${_pats[@]}"; do IFS=$'\t' read -r regex _ <<< "$entry" if grep -qE "$regex" <<< "$files"; then return 0 @@ -144,9 +163,10 @@ jobs: } decide() { - local decision="$1" reason="$2" files="${3:-}" - echo "Decision: run_docker_tests=$decision ($reason)" + local decision="$1" newton_decision="$2" reason="$3" files="${4:-}" + echo "Decision: run_docker_tests=$decision run_newton_tests=$newton_decision ($reason)" echo "run_docker_tests=$decision" >> "$GITHUB_OUTPUT" + echo "run_newton_tests=$newton_decision" >> "$GITHUB_OUTPUT" { echo "## Docker test gating" echo "" @@ -156,6 +176,8 @@ jobs: echo "Docker tests will be **skipped**: $reason." fi echo "" + echo "Newton kitless job: **$([ "$newton_decision" = "true" ] && echo run || echo skip)**." + echo "" echo "Triggered jobs: $triggered_jobs." if [ -n "$files" ]; then echo "" @@ -165,23 +187,25 @@ jobs: } if [ "$EVENT_NAME" != "pull_request" ]; then - decide true "non-PR event ($EVENT_NAME)" + decide true true "non-PR event ($EVENT_NAME)" exit 0 fi if ! changed_files="$(gh api --paginate "repos/$REPO/pulls/$PR_NUMBER/files" --jq '.[].filename')"; then # Fail-safe: a transient API error must not block merge. Default to running. echo "::warning::Could not list changed files; defaulting to running tests" - decide true "fail-safe (could not list changed files)" + decide true true "fail-safe (could not list changed files)" exit 0 fi printf '%s\n' "$changed_files" - if any_match "$changed_files"; then - decide true "relevant paths changed" "$changed_files" + if any_match "$changed_files" patterns; then + newton_decision=false + any_match "$changed_files" newton_patterns && newton_decision=true + decide true "$newton_decision" "relevant paths changed" "$changed_files" else - decide false "no relevant paths changed" "$changed_files" + decide false false "no relevant paths changed" "$changed_files" fi config: @@ -280,7 +304,7 @@ jobs: needs: [changes, config] if: >- github.event_name != 'push' && - needs.changes.outputs.run_docker_tests == 'true' + needs.changes.outputs.run_newton_tests == 'true' steps: - name: Checkout Code uses: actions/checkout@v6 From 194024ed1c470a1a74c66bfe2fa3b896d5fdecc3 Mon Sep 17 00:00:00 2001 From: jichuanh Date: Fri, 10 Jul 2026 00:49:24 +0000 Subject: [PATCH 09/13] Pull deps-cached image inside the ECR action The ECR login lives in a temporary docker config that the action removes in its cleanup step, so a job step running after the action cannot pull. On a deps-cache hit the action only aliased the commit tag registry-side, leaving jobs that docker-run the image in later steps of the same job without a local copy and without credentials to fetch one. Add an opt-in pull-on-deps-hit input (default false, no behavior change for existing callers) that pulls and tags the aliased image while the login is still valid, and reduce the Newton job's local image check to a fail-fast assertion. --- .../actions/ecr-build-push-pull/action.yml | 29 +++++++++++++++++++ .github/workflows/build.yaml | 19 ++++++------ 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/.github/actions/ecr-build-push-pull/action.yml b/.github/actions/ecr-build-push-pull/action.yml index b661d4b9fd62..9b17041ed08e 100644 --- a/.github/actions/ecr-build-push-pull/action.yml +++ b/.github/actions/ecr-build-push-pull/action.yml @@ -37,6 +37,14 @@ inputs: description: Tag used for the ECR layer cache image (e.g. "cache-base", "cache-curobo"). required: false default: 'cache' + pull-on-deps-hit: + description: > + Pull the image locally after a deps-cache hit. Needed by jobs that run + the image in later steps of the same job: the ECR login lives in a + temporary docker config that is removed when this action ends, so + steps after the action cannot pull. + required: false + default: 'false' runs: using: composite steps: @@ -310,6 +318,27 @@ runs: docker tag "${{ inputs.image-tag }}" "${DEPS_ECR_IMAGE}" docker push "${DEPS_ECR_IMAGE}" + ##### 8b: Pull deps-cached image for same-job use ##### + + # On a deps-cache hit step 5 only aliases the commit tag registry-side. + # Jobs that docker-run the image in later steps opt in here, while the + # temporary docker config (and its ECR login) still exists. + + - name: Pull deps-cached image + if: > + steps.deps-cache.outputs.deps-cache-hit == 'true' && + inputs.pull-on-deps-hit == 'true' + shell: bash + run: | + if docker image inspect "${{ inputs.image-tag }}" >/dev/null 2>&1; then + echo "🟢 Image already available locally, skipping pull" + else + echo "🔵 Pulling ${ECR_IMAGE} from ECR..." + docker pull "${ECR_IMAGE}" + docker tag "${ECR_IMAGE}" "${{ inputs.image-tag }}" + echo "🟢 Image pulled and tagged as ${{ inputs.image-tag }}" + fi + ##### 9: Cleanup docker config ##### - name: Cleanup docker config diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 736de2ee69d1..ff9e7885567b 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -329,21 +329,22 @@ jobs: isaacsim-version: "24.04" dockerfile-path: docker/Dockerfile.newton cache-tag: cache-newton - - # On a deps-cache hit the action only aliases the per-commit tag in the - # registry without pulling it, so fetch it before the docker run steps. + # The smoke below docker-runs the image in this same job, and the + # ECR login only exists while the action runs, so the action itself + # must pull on a deps-cache hit. + pull-on-deps-hit: "true" + + # Every action path (full build --load, exact-tag pull, deps-hit pull) + # leaves the image local; fail fast with a clear message if not, instead + # of letting docker run attempt a docker.io pull of the CI-only tag. - name: Ensure image is available locally shell: bash run: | set -euo pipefail IMAGE_TAG="${{ needs.config.outputs.ci_image_tag }}-newton" if ! docker image inspect "${IMAGE_TAG}" >/dev/null 2>&1; then - if [ -z "${ECR_IMAGE:-}" ]; then - echo "::error::Image ${IMAGE_TAG} is not available locally and no ECR image is configured." - exit 1 - fi - docker pull "${ECR_IMAGE}" - docker tag "${ECR_IMAGE}" "${IMAGE_TAG}" + echo "::error::Image ${IMAGE_TAG} is not available locally after the build action." + exit 1 fi # Mirrors the verification command documented in From b9348fd7576d7fbcc242d9b2aeafaa434300c447 Mon Sep 17 00:00:00 2001 From: jichuanh Date: Fri, 10 Jul 2026 10:31:55 +0000 Subject: [PATCH 10/13] Move the Newton docker job to its own workflow The Newton kitless job is an install/build test of the shipped container artifact, matching the Installation Tests workflow in shape rather than the build.yaml docker jobs that feed the test matrix. Give it a dedicated workflow with its own single-output changes gate and concurrency group, and revert build.yaml's changes job to its original single-gate form (keeping the pyproject.toml and isaaclab.sh trigger patterns, which the other images consume too). The job's steps, the ecr-build-push-pull usage with pull-on-deps-hit, the per-commit tag scheme, and the ECR cache tags are unchanged, so cache history carries over. --- .github/workflows/build.yaml | 142 +--------------- .github/workflows/newton-docker.yml | 254 ++++++++++++++++++++++++++++ 2 files changed, 262 insertions(+), 134 deletions(-) create mode 100644 .github/workflows/newton-docker.yml diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index ff9e7885567b..1c66c7a7b36e 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -80,7 +80,6 @@ jobs: runs-on: ubuntu-latest outputs: run_docker_tests: ${{ steps.detect.outputs.run_docker_tests }} - run_newton_tests: ${{ steps.detect.outputs.run_newton_tests }} steps: - id: detect env: @@ -110,21 +109,6 @@ jobs: $'^\\.github/actions/\tCI actions' $'^\\.github/test-subsets/\tCI test subset config' ) - # The Newton kitless job is an install/build test, not a library-code - # test (the test matrix covers code), so it gates only on the install - # inputs — the same set that feeds the deps-hash in the - # ecr-build-push-pull action — plus its own wiring. Source-level - # kitless regressions are accepted to surface on the next - # install-input change rather than on every source PR. - newton_patterns=( - $'^docker/Dockerfile\\.newton$\tNewton image definition' - $'^isaaclab\\.sh$\tInstall CLI wrapper' - $'^pyproject\\.toml$\tRoot dependency manifest' - $'^source/[^/]+/pyproject\\.toml$\tPackage manifests' - $'^source/isaaclab/isaaclab/cli/\tInstall CLI implementation' - $'^\\.github/workflows/build\\.yaml$\tThis workflow file' - $'^\\.github/actions/ecr-build-push-pull/\tECR build-cache action' - ) if [ "$EVENT_NAME" = "push" ]; then triggered_jobs="Docker base build job + rendering-correctness + rendering-correctness-kitless" else @@ -152,8 +136,7 @@ jobs: any_match() { local files="$1" entry regex - local -n _pats="$2" - for entry in "${_pats[@]}"; do + for entry in "${patterns[@]}"; do IFS=$'\t' read -r regex _ <<< "$entry" if grep -qE "$regex" <<< "$files"; then return 0 @@ -163,10 +146,9 @@ jobs: } decide() { - local decision="$1" newton_decision="$2" reason="$3" files="${4:-}" - echo "Decision: run_docker_tests=$decision run_newton_tests=$newton_decision ($reason)" + local decision="$1" reason="$2" files="${3:-}" + echo "Decision: run_docker_tests=$decision ($reason)" echo "run_docker_tests=$decision" >> "$GITHUB_OUTPUT" - echo "run_newton_tests=$newton_decision" >> "$GITHUB_OUTPUT" { echo "## Docker test gating" echo "" @@ -176,8 +158,6 @@ jobs: echo "Docker tests will be **skipped**: $reason." fi echo "" - echo "Newton kitless job: **$([ "$newton_decision" = "true" ] && echo run || echo skip)**." - echo "" echo "Triggered jobs: $triggered_jobs." if [ -n "$files" ]; then echo "" @@ -187,25 +167,23 @@ jobs: } if [ "$EVENT_NAME" != "pull_request" ]; then - decide true true "non-PR event ($EVENT_NAME)" + decide true "non-PR event ($EVENT_NAME)" exit 0 fi if ! changed_files="$(gh api --paginate "repos/$REPO/pulls/$PR_NUMBER/files" --jq '.[].filename')"; then # Fail-safe: a transient API error must not block merge. Default to running. echo "::warning::Could not list changed files; defaulting to running tests" - decide true true "fail-safe (could not list changed files)" + decide true "fail-safe (could not list changed files)" exit 0 fi printf '%s\n' "$changed_files" - if any_match "$changed_files" patterns; then - newton_decision=false - any_match "$changed_files" newton_patterns && newton_decision=true - decide true "$newton_decision" "relevant paths changed" "$changed_files" + if any_match "$changed_files"; then + decide true "relevant paths changed" "$changed_files" else - decide false false "no relevant paths changed" "$changed_files" + decide false "no relevant paths changed" "$changed_files" fi config: @@ -297,110 +275,6 @@ jobs: dockerfile-path: docker/Dockerfile.curobo cache-tag: cache-curobo - build-newton: - name: Build Newton Kitless Docker Image - runs-on: [self-hosted, gpu] - timeout-minutes: 120 - needs: [changes, config] - if: >- - github.event_name != 'push' && - needs.changes.outputs.run_newton_tests == 'true' - steps: - - name: Checkout Code - uses: actions/checkout@v6 - with: - fetch-depth: 1 - lfs: true - - # Same ECR-cached setup as the other image builds: an existing per-commit - # or deps- tag in ECR skips the build entirely, and full builds - # reuse the registry layer cache. Dockerfile.newton builds from the public - # ubuntu:24.04 image, so the (historically named) isaacsim-base-image / - # isaacsim-version inputs only pin the base image whose digest feeds the - # deps hash; the ISAACSIM_* build-args the action passes are not declared - # by this Dockerfile and are ignored. The kitless guarantee stays asserted - # inside the Dockerfile itself (no isaacsim distribution, no _isaac_sim - # link), so any path that produces the image has already proven it. - - name: Build and push to ECR - uses: ./.github/actions/ecr-build-push-pull - with: - image-tag: ${{ needs.config.outputs.ci_image_tag }}-newton - isaacsim-base-image: ubuntu - isaacsim-version: "24.04" - dockerfile-path: docker/Dockerfile.newton - cache-tag: cache-newton - # The smoke below docker-runs the image in this same job, and the - # ECR login only exists while the action runs, so the action itself - # must pull on a deps-cache hit. - pull-on-deps-hit: "true" - - # Every action path (full build --load, exact-tag pull, deps-hit pull) - # leaves the image local; fail fast with a clear message if not, instead - # of letting docker run attempt a docker.io pull of the CI-only tag. - - name: Ensure image is available locally - shell: bash - run: | - set -euo pipefail - IMAGE_TAG="${{ needs.config.outputs.ci_image_tag }}-newton" - if ! docker image inspect "${IMAGE_TAG}" >/dev/null 2>&1; then - echo "::error::Image ${IMAGE_TAG} is not available locally after the build action." - exit 1 - fi - - # Mirrors the verification command documented in - # docs/source/deployment/docker.rst and the canonical cartpole training - # probe used by the installation tests. source/, scripts/ and apps/ are - # mounted from the checkout so a deps-cached image still runs this PR's - # code: the isaaclab packages are installed editable from source/, so the - # baked venv resolves imports through the mounted tree. - - name: Run training smoke test - shell: bash - run: | - set -euo pipefail - log="$(mktemp)" - docker run --rm --gpus all --network host \ - -v "$PWD/source":/workspace/isaaclab/source \ - -v "$PWD/scripts":/workspace/isaaclab/scripts \ - -v "$PWD/apps":/workspace/isaaclab/apps \ - "${{ needs.config.outputs.ci_image_tag }}-newton" \ - isaaclab train --rl_library rsl_rl \ - --task Isaac-Cartpole-Direct \ - --num_envs 16 \ - presets=newton_mjwarp \ - --max_iterations 5 2>&1 | tee "$log" - if grep -q "Traceback (most recent call last):" "$log"; then - echo "::error::Training smoke test raised a traceback." - exit 1 - fi - if ! grep -q "Training time:" "$log"; then - echo "::error::Training smoke test did not report 'Training time:'." - exit 1 - fi - - - name: Verify Newton runtime user is non-root - shell: bash - run: | - set -euo pipefail - runtime_identity="$(docker run --rm --entrypoint bash "${{ needs.config.outputs.ci_image_tag }}-newton" \ - -lc 'printf "%s %s %s\n" "$(id -u)" "$(id -g)" "$(id -un 2>/dev/null || true)"')" - read -r runtime_uid runtime_gid runtime_user <<< "${runtime_identity}" - echo "Newton runtime identity: uid=${runtime_uid} gid=${runtime_gid} user=${runtime_user}" - if [ "${runtime_uid}" = "0" ]; then - echo "::error::Newton Docker image must not run as root by default." - exit 1 - fi - - # Keep the self-hosted runner's disk lean: drop the local tags after the - # run; the layers stay cached in ECR for the next build. - - name: Remove local image - if: always() - shell: bash - run: | - docker image rm -f "${{ needs.config.outputs.ci_image_tag }}-newton" || true - if [ -n "${ECR_IMAGE:-}" ]; then - docker image rm -f "${ECR_IMAGE}" || true - fi - #endregion #region test jobs diff --git a/.github/workflows/newton-docker.yml b/.github/workflows/newton-docker.yml new file mode 100644 index 000000000000..23bcd6f54524 --- /dev/null +++ b/.github/workflows/newton-docker.yml @@ -0,0 +1,254 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +# Builds the kitless Newton training image (docker/Dockerfile.newton) and runs +# the canonical cartpole training smoke inside it. This is an install/build +# test of the shipped container artifact: library code is covered by the +# build.yaml test matrix, and the documented native install paths are covered +# by the Installation Tests workflow. + +name: Newton Docker Image + +on: + pull_request: + types: [opened, synchronize, reopened] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + pull-requests: read + +jobs: + changes: + name: Detect Changes + runs-on: ubuntu-latest + outputs: + run_newton_tests: ${{ steps.detect.outputs.run_newton_tests }} + steps: + - id: detect + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + EVENT_NAME: ${{ github.event_name }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + + # This job is an install/build test, so it gates only on the install + # inputs — the same set that feeds the deps-hash in the + # ecr-build-push-pull action — plus its own wiring. Source-level + # kitless regressions are accepted to surface on the next + # install-input change rather than on every source PR. + # + # The build job skips via `if:` and reports green to branch + # protection, which is why there is no workflow-level `paths:` filter + # (a not-triggered required check would block the PR forever). + patterns=( + $'^docker/Dockerfile\\.newton$\tNewton image definition' + $'^isaaclab\\.sh$\tInstall CLI wrapper' + $'^pyproject\\.toml$\tRoot dependency manifest' + $'^source/[^/]+/pyproject\\.toml$\tPackage manifests' + $'^source/isaaclab/isaaclab/cli/\tInstall CLI implementation' + $'^\\.github/workflows/newton-docker\\.yml$\tThis workflow file' + $'^\\.github/actions/ecr-build-push-pull/\tECR build-cache action' + ) + + render_table() { + local files="$1" entry regex desc count sample shown + echo "| Pattern | What it covers | Matched files |" + echo "|---|---|---|" + for entry in "${patterns[@]}"; do + IFS=$'\t' read -r regex desc <<< "$entry" + # escape | so it doesn't end the markdown table cell mid-regex + shown="${regex//|/\\|}" + count=$(grep -cE "$regex" <<< "$files" || true) + if [ "$count" -gt 0 ]; then + sample=$(grep -m 3 -E "$regex" <<< "$files" | paste -sd ', ' -) + [ "$count" -gt 3 ] && sample="$sample (and $((count - 3)) more)" + echo "| \`$shown\` | $desc | $sample |" + else + echo "| \`$shown\` | $desc | - |" + fi + done + } + + any_match() { + local files="$1" entry regex + for entry in "${patterns[@]}"; do + IFS=$'\t' read -r regex _ <<< "$entry" + if grep -qE "$regex" <<< "$files"; then + return 0 + fi + done + return 1 + } + + decide() { + local decision="$1" reason="$2" files="${3:-}" + echo "Decision: run_newton_tests=$decision ($reason)" + echo "run_newton_tests=$decision" >> "$GITHUB_OUTPUT" + { + echo "## Newton docker gating" + echo "" + if [ "$decision" = "true" ]; then + echo "The Newton docker build and smoke will **run**: $reason." + else + echo "The Newton docker build and smoke will be **skipped**: $reason." + fi + if [ -n "$files" ]; then + echo "" + render_table "$files" + fi + } >> "$GITHUB_STEP_SUMMARY" + } + + if [ "$EVENT_NAME" != "pull_request" ]; then + decide true "non-PR event ($EVENT_NAME)" + exit 0 + fi + + if ! changed_files="$(gh api --paginate "repos/$REPO/pulls/$PR_NUMBER/files" --jq '.[].filename')"; then + # Fail-safe: a transient API error must not block merge. Default to running. + echo "::warning::Could not list changed files; defaulting to running tests" + decide true "fail-safe (could not list changed files)" + exit 0 + fi + + printf '%s\n' "$changed_files" + + if any_match "$changed_files"; then + decide true "relevant paths changed" "$changed_files" + else + decide false "no relevant paths changed" "$changed_files" + fi + + build-newton: + name: Build Newton Kitless Docker Image + runs-on: [self-hosted, gpu] + timeout-minutes: 120 + needs: [changes] + if: needs.changes.outputs.run_newton_tests == 'true' + steps: + - name: Checkout Code + uses: actions/checkout@v6 + with: + fetch-depth: 1 + lfs: true + + # Mirrors the per-commit tag scheme of build.yaml's config job so the + # ECR tags stay in the same family as the other CI images. + - name: Compute image tag + id: tag + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + PR_NUMBER: ${{ github.event.pull_request.number }} + REF_NAME: ${{ github.ref_name }} + SHA: ${{ github.sha }} + run: | + set -euo pipefail + if [ "$EVENT_NAME" = "pull_request" ]; then + ref_component="pr-${PR_NUMBER}" + else + ref_component="$REF_NAME" + fi + # Sanitize the ref name for use as a Docker tag suffix. + sanitized_ref=$(echo "$ref_component" | sed 's/[^a-zA-Z0-9._-]/-/g') + echo "image_tag=isaac-lab-ci:${sanitized_ref}-${SHA}-newton" >> "$GITHUB_OUTPUT" + echo "CI image tag: isaac-lab-ci:${sanitized_ref}-${SHA}-newton" + + # Same ECR-cached setup as the build.yaml image builds: an existing + # per-commit or deps- tag in ECR skips the build entirely, and full + # builds reuse the registry layer cache. Dockerfile.newton builds from the + # public ubuntu:24.04 image, so the (historically named) + # isaacsim-base-image / isaacsim-version inputs only pin the base image + # whose digest feeds the deps hash; the ISAACSIM_* build-args the action + # passes are not declared by this Dockerfile and are ignored. The kitless + # guarantee stays asserted inside the Dockerfile itself (no isaacsim + # distribution, no _isaac_sim link), so any path that produces the image + # has already proven it. + - name: Build and push to ECR + uses: ./.github/actions/ecr-build-push-pull + with: + image-tag: ${{ steps.tag.outputs.image_tag }} + isaacsim-base-image: ubuntu + isaacsim-version: "24.04" + dockerfile-path: docker/Dockerfile.newton + cache-tag: cache-newton + # The smoke below docker-runs the image in this same job, and the + # ECR login only exists while the action runs, so the action itself + # must pull on a deps-cache hit. + pull-on-deps-hit: "true" + + # Every action path (full build --load, exact-tag pull, deps-hit pull) + # leaves the image local; fail fast with a clear message if not, instead + # of letting docker run attempt a docker.io pull of the CI-only tag. + - name: Ensure image is available locally + shell: bash + run: | + set -euo pipefail + IMAGE_TAG="${{ steps.tag.outputs.image_tag }}" + if ! docker image inspect "${IMAGE_TAG}" >/dev/null 2>&1; then + echo "::error::Image ${IMAGE_TAG} is not available locally after the build action." + exit 1 + fi + + # Mirrors the verification command documented in + # docs/source/deployment/docker.rst and the canonical cartpole training + # probe used by the installation tests. source/, scripts/ and apps/ are + # mounted from the checkout so a deps-cached image still runs this PR's + # code: the isaaclab packages are installed editable from source/, so the + # baked venv resolves imports through the mounted tree. + - name: Run training smoke test + shell: bash + run: | + set -euo pipefail + log="$(mktemp)" + docker run --rm --gpus all --network host \ + -v "$PWD/source":/workspace/isaaclab/source \ + -v "$PWD/scripts":/workspace/isaaclab/scripts \ + -v "$PWD/apps":/workspace/isaaclab/apps \ + "${{ steps.tag.outputs.image_tag }}" \ + isaaclab train --rl_library rsl_rl \ + --task Isaac-Cartpole-Direct \ + --num_envs 16 \ + presets=newton_mjwarp \ + --max_iterations 5 2>&1 | tee "$log" + if grep -q "Traceback (most recent call last):" "$log"; then + echo "::error::Training smoke test raised a traceback." + exit 1 + fi + if ! grep -q "Training time:" "$log"; then + echo "::error::Training smoke test did not report 'Training time:'." + exit 1 + fi + + - name: Verify Newton runtime user is non-root + shell: bash + run: | + set -euo pipefail + runtime_identity="$(docker run --rm --entrypoint bash "${{ steps.tag.outputs.image_tag }}" \ + -lc 'printf "%s %s %s\n" "$(id -u)" "$(id -g)" "$(id -un 2>/dev/null || true)"')" + read -r runtime_uid runtime_gid runtime_user <<< "${runtime_identity}" + echo "Newton runtime identity: uid=${runtime_uid} gid=${runtime_gid} user=${runtime_user}" + if [ "${runtime_uid}" = "0" ]; then + echo "::error::Newton Docker image must not run as root by default." + exit 1 + fi + + # Keep the self-hosted runner's disk lean: drop the local tags after the + # run; the layers stay cached in ECR for the next build. + - name: Remove local image + if: always() + shell: bash + run: | + docker image rm -f "${{ steps.tag.outputs.image_tag }}" || true + if [ -n "${ECR_IMAGE:-}" ]; then + docker image rm -f "${ECR_IMAGE}" || true + fi From a71aac66425e2b5a0b92411d5ab28d5b2aa3ed49 Mon Sep 17 00:00:00 2001 From: jichuanh Date: Fri, 10 Jul 2026 10:40:24 +0000 Subject: [PATCH 11/13] Restore build.yaml to its pre-PR form The Newton docker workflow carries its own pyproject.toml and isaaclab.sh trigger patterns, so the copies added to build.yaml's shared docker-test gate no longer serve this PR. Removing them leaves build.yaml untouched by the PR; widening the shared gate for the other images can be considered separately. --- .github/workflows/build.yaml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 1c66c7a7b36e..39f9ffab1b2c 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -102,8 +102,6 @@ jobs: $'^tools/\tBuild tooling' $'^apps/\tStandalone apps' $'^scripts/\tStandalone scripts' - $'^pyproject\\.toml$\tRoot dependency manifest' - $'^isaaclab\\.sh$\tInstall CLI wrapper' $'^\\.github/workflows/build\\.yaml$\tThis workflow file' $'^\\.github/workflows/config\\.yaml$\tBase image config' $'^\\.github/actions/\tCI actions' From fd2d61e72f8c469f042a5c57327a56824408a8b2 Mon Sep 17 00:00:00 2001 From: jichuanh Date: Fri, 10 Jul 2026 20:57:08 +0000 Subject: [PATCH 12/13] Extract the path-gating detect step into a composite action Three workflows (build.yaml, install-ci.yml, newton-docker.yml) carried near-identical ~80-line copies of the changed-paths gating engine, differing only in pattern lists, output names, and summary wording. Move the engine into .github/actions/detect-changes with patterns/summary-title/summary-note inputs and a single run output; each caller shrinks to a sparse checkout plus one action call, with its existing output name mapped so downstream if: conditions are untouched. Gating semantics are preserved per workflow: same patterns, fail-open on non-PR events and on changed-file listing failures, and the skip-reports-green contract for branch protection. --- .github/actions/detect-changes/action.yml | 137 ++++++++++++++++++++++ .github/workflows/build.yaml | 131 +++++---------------- .github/workflows/install-ci.yml | 109 ++++------------- .github/workflows/newton-docker.yml | 122 ++++--------------- 4 files changed, 216 insertions(+), 283 deletions(-) create mode 100644 .github/actions/detect-changes/action.yml diff --git a/.github/actions/detect-changes/action.yml b/.github/actions/detect-changes/action.yml new file mode 100644 index 000000000000..58a36fc111a2 --- /dev/null +++ b/.github/actions/detect-changes/action.yml @@ -0,0 +1,137 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +name: 'Detect Changed Paths' +description: > + Decides whether path-gated jobs should run for the current event. Pull + requests run the gated jobs only when a changed file matches one of the + given patterns; non-PR events and changed-file listing failures fail open + (run). Writes a gating table to the step summary. Consuming jobs should + skip via `if:` so required checks report green instead of never + triggering (a workflow-level `paths:` filter would leave a required + check pending forever). + +inputs: + patterns: + description: > + One entry per line: an extended regex matched against the changed + file paths, then whitespace, then a human-readable description for + the step-summary table. The regex itself must not contain + whitespace (the first whitespace run is the column separator). + required: true + summary-title: + description: 'Heading for the step-summary section.' + required: false + default: 'Path gating' + summary-note: + description: 'Optional extra line appended below the decision.' + required: false + default: '' + +outputs: + run: + description: '"true" when the gated jobs should run, "false" otherwise.' + value: ${{ steps.detect.outputs.run }} + +runs: + using: composite + steps: + - id: detect + shell: bash + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + EVENT_NAME: ${{ github.event_name }} + REPO: ${{ github.repository }} + PATTERNS_INPUT: ${{ inputs.patterns }} + SUMMARY_TITLE: ${{ inputs.summary-title }} + SUMMARY_NOTE: ${{ inputs.summary-note }} + run: | + set -euo pipefail + + # Parse "regexdescription" lines into parallel arrays. + regexes=() + descriptions=() + while IFS= read -r line; do + [[ -z "${line//[[:space:]]/}" ]] && continue + regex="${line%%[[:space:]]*}" + desc="${line#"$regex"}" + desc="${desc#"${desc%%[![:space:]]*}"}" + regexes+=("$regex") + descriptions+=("${desc:-$regex}") + done <<< "$PATTERNS_INPUT" + + render_table() { + local files="$1" i regex desc count sample shown + echo "| Pattern | What it covers | Matched files |" + echo "|---|---|---|" + for i in "${!regexes[@]}"; do + regex="${regexes[$i]}" + desc="${descriptions[$i]}" + # escape | so it doesn't end the markdown table cell mid-regex + shown="${regex//|/\\|}" + count=$(grep -cE "$regex" <<< "$files" || true) + if [ "$count" -gt 0 ]; then + sample=$(grep -m 3 -E "$regex" <<< "$files" | paste -sd ', ' -) + [ "$count" -gt 3 ] && sample="$sample (and $((count - 3)) more)" + echo "| \`$shown\` | $desc | $sample |" + else + echo "| \`$shown\` | $desc | - |" + fi + done + } + + any_match() { + local files="$1" regex + for regex in "${regexes[@]}"; do + if grep -qE "$regex" <<< "$files"; then + return 0 + fi + done + return 1 + } + + decide() { + local decision="$1" reason="$2" files="${3:-}" + echo "Decision: run=$decision ($reason)" + echo "run=$decision" >> "$GITHUB_OUTPUT" + { + echo "## ${SUMMARY_TITLE}" + echo "" + if [ "$decision" = "true" ]; then + echo "Gated jobs will **run**: $reason." + else + echo "Gated jobs will be **skipped**: $reason." + fi + if [ -n "$SUMMARY_NOTE" ]; then + echo "" + echo "$SUMMARY_NOTE" + fi + if [ -n "$files" ]; then + echo "" + render_table "$files" + fi + } >> "$GITHUB_STEP_SUMMARY" + } + + if [ "$EVENT_NAME" != "pull_request" ]; then + decide true "non-PR event ($EVENT_NAME)" + exit 0 + fi + + if ! changed_files="$(gh api --paginate "repos/$REPO/pulls/$PR_NUMBER/files" --jq '.[].filename')"; then + # Fail-safe: a transient API error must not block merge. Default to running. + echo "::warning::Could not list changed files; defaulting to running tests" + decide true "fail-safe (could not list changed files)" + exit 0 + fi + + printf '%s\n' "$changed_files" + + if any_match "$changed_files"; then + decide true "relevant paths changed" "$changed_files" + else + decide false "no relevant paths changed" "$changed_files" + fi diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 39f9ffab1b2c..bf6e9d249dad 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -79,110 +79,37 @@ jobs: name: Detect Changes runs-on: ubuntu-latest outputs: - run_docker_tests: ${{ steps.detect.outputs.run_docker_tests }} + run_docker_tests: ${{ steps.detect.outputs.run }} steps: - - id: detect - env: - GH_TOKEN: ${{ github.token }} - PR_NUMBER: ${{ github.event.pull_request.number }} - EVENT_NAME: ${{ github.event_name }} - REPO: ${{ github.repository }} - run: | - set -euo pipefail - - # Docker test jobs run only when paths in the patterns table change. - # Otherwise they skip via `if:` and report green to branch protection, - # which is why we don't use a workflow-level `paths:` filter (a - # not-triggered required check would block the PR forever). - # config.yaml is included because it controls the base image names and - # tags consumed by the Docker build jobs. - patterns=( - $'^source/\tLibrary source code' - $'^docker/\tContainer build inputs' - $'^tools/\tBuild tooling' - $'^apps/\tStandalone apps' - $'^scripts/\tStandalone scripts' - $'^\\.github/workflows/build\\.yaml$\tThis workflow file' - $'^\\.github/workflows/config\\.yaml$\tBase image config' - $'^\\.github/actions/\tCI actions' - $'^\\.github/test-subsets/\tCI test subset config' - ) - if [ "$EVENT_NAME" = "push" ]; then - triggered_jobs="Docker base build job + rendering-correctness + rendering-correctness-kitless" - else - triggered_jobs="Docker build jobs + all test-* matrix jobs" - fi - - render_table() { - local files="$1" entry regex desc count sample shown - echo "| Pattern | What it covers | Matched files |" - echo "|---|---|---|" - for entry in "${patterns[@]}"; do - IFS=$'\t' read -r regex desc <<< "$entry" - # escape | so it doesn't end the markdown table cell mid-regex - shown="${regex//|/\\|}" - count=$(grep -cE "$regex" <<< "$files" || true) - if [ "$count" -gt 0 ]; then - sample=$(grep -m 3 -E "$regex" <<< "$files" | paste -sd ', ' -) - [ "$count" -gt 3 ] && sample="$sample (and $((count - 3)) more)" - echo "| \`$shown\` | $desc | $sample |" - else - echo "| \`$shown\` | $desc | - |" - fi - done - } - - any_match() { - local files="$1" entry regex - for entry in "${patterns[@]}"; do - IFS=$'\t' read -r regex _ <<< "$entry" - if grep -qE "$regex" <<< "$files"; then - return 0 - fi - done - return 1 - } - - decide() { - local decision="$1" reason="$2" files="${3:-}" - echo "Decision: run_docker_tests=$decision ($reason)" - echo "run_docker_tests=$decision" >> "$GITHUB_OUTPUT" - { - echo "## Docker test gating" - echo "" - if [ "$decision" = "true" ]; then - echo "Docker tests will **run**: $reason." - else - echo "Docker tests will be **skipped**: $reason." - fi - echo "" - echo "Triggered jobs: $triggered_jobs." - if [ -n "$files" ]; then - echo "" - render_table "$files" - fi - } >> "$GITHUB_STEP_SUMMARY" - } - - if [ "$EVENT_NAME" != "pull_request" ]; then - decide true "non-PR event ($EVENT_NAME)" - exit 0 - fi - - if ! changed_files="$(gh api --paginate "repos/$REPO/pulls/$PR_NUMBER/files" --jq '.[].filename')"; then - # Fail-safe: a transient API error must not block merge. Default to running. - echo "::warning::Could not list changed files; defaulting to running tests" - decide true "fail-safe (could not list changed files)" - exit 0 - fi - - printf '%s\n' "$changed_files" + # Local composite actions need the repo present; only the action + # directory is required. + - uses: actions/checkout@v6 + with: + fetch-depth: 1 + sparse-checkout: .github/actions/detect-changes + sparse-checkout-cone-mode: false - if any_match "$changed_files"; then - decide true "relevant paths changed" "$changed_files" - else - decide false "no relevant paths changed" "$changed_files" - fi + # Docker test jobs run only when paths in the patterns table change. + # config.yaml is included because it controls the base image names and + # tags consumed by the Docker build jobs. + - id: detect + uses: ./.github/actions/detect-changes + with: + summary-title: Docker test gating + summary-note: >- + Triggered jobs: ${{ github.event_name == 'push' + && 'Docker base build job + rendering-correctness + rendering-correctness-kitless' + || 'Docker build jobs + all test-* matrix jobs' }}. + patterns: | + ^source/ Library source code + ^docker/ Container build inputs + ^tools/ Build tooling + ^apps/ Standalone apps + ^scripts/ Standalone scripts + ^\.github/workflows/build\.yaml$ This workflow file + ^\.github/workflows/config\.yaml$ Base image config + ^\.github/actions/ CI actions + ^\.github/test-subsets/ CI test subset config config: name: Load Config diff --git a/.github/workflows/install-ci.yml b/.github/workflows/install-ci.yml index 226222d425bd..15db0a3e8467 100644 --- a/.github/workflows/install-ci.yml +++ b/.github/workflows/install-ci.yml @@ -36,93 +36,32 @@ jobs: name: Detect Changes runs-on: ubuntu-latest outputs: - run_install_tests: ${{ steps.detect.outputs.run_install_tests }} + run_install_tests: ${{ steps.detect.outputs.run }} steps: - - id: detect - env: - GH_TOKEN: ${{ github.token }} - PR_NUMBER: ${{ github.event.pull_request.number }} - EVENT_NAME: ${{ github.event_name }} - REPO: ${{ github.repository }} - run: | - set -euo pipefail - - # Installation tests run only when paths in the patterns table change. - # Otherwise the test job skips via `if:` and reports green to branch - # protection, which is why we don't use a workflow-level `paths:` - # filter (a not-triggered required check would block the PR forever). - patterns=( - $'^apps/\tStandalone apps' - $'^tools/\tBuild tooling' - $'^source/\tLibrary source code' - $'^\\.github/actions/run-package-tests/\tTest action' - $'^\\.github/actions/install-ci-(collect|run)/\tInstall-ci composite actions' - $'^\\.github/workflows/install-ci\\.yml$\tThis workflow file' - $'^VERSION$\tVersion file' - $'(^|/)pyproject\\.toml$\tPython project metadata' - $'(^|/)environment\\.ya?ml$\tConda environment file' - ) - - render_table() { - local files="$1" entry regex desc count sample shown - echo "| Pattern | What it covers | Matched files |" - echo "|---|---|---|" - for entry in "${patterns[@]}"; do - IFS=$'\t' read -r regex desc <<< "$entry" - # escape | so it doesn't end the markdown table cell mid-regex - shown="${regex//|/\\|}" - count=$(grep -cE "$regex" <<< "$files" || true) - if [ "$count" -gt 0 ]; then - sample=$(grep -m 3 -E "$regex" <<< "$files" | paste -sd ', ' -) - [ "$count" -gt 3 ] && sample="$sample (and $((count - 3)) more)" - echo "| \`$shown\` | $desc | $sample |" - else - echo "| \`$shown\` | $desc | - |" - fi - done - } - - any_match() { - local files="$1" entry regex - for entry in "${patterns[@]}"; do - IFS=$'\t' read -r regex _ <<< "$entry" - if grep -qE "$regex" <<< "$files"; then - return 0 - fi - done - return 1 - } - - decide() { - local decision="$1" reason="$2" files="${3:-}" - echo "Decision: run_install_tests=$decision ($reason)" - echo "run_install_tests=$decision" >> "$GITHUB_OUTPUT" - { - if [ -n "$files" ]; then - render_table "$files" - fi - } >> "$GITHUB_STEP_SUMMARY" - } - - if [ "$EVENT_NAME" != "pull_request" ]; then - decide true "non-PR event ($EVENT_NAME)" - exit 0 - fi - - if ! changed_files="$(gh api --paginate "repos/$REPO/pulls/$PR_NUMBER/files" --jq '.[].filename')"; then - # Fail-safe: a transient API error must not block merge. Default to running. - echo "::warning::Could not list changed files; defaulting to running tests" - decide true "fail-safe (could not list changed files)" - exit 0 - fi - - printf '%s\n' "$changed_files" + # Local composite actions need the repo present; only the action + # directory is required. + - uses: actions/checkout@v6 + with: + fetch-depth: 1 + sparse-checkout: .github/actions/detect-changes + sparse-checkout-cone-mode: false - if any_match "$changed_files"; then - decide true "relevant paths changed" "$changed_files" - else - decide false "no relevant paths changed" "$changed_files" - fi + # Installation tests run only when paths in the patterns table change. + - id: detect + uses: ./.github/actions/detect-changes + with: + summary-title: Installation test gating + patterns: | + ^apps/ Standalone apps + ^tools/ Build tooling + ^source/ Library source code + ^\.github/actions/run-package-tests/ Test action + ^\.github/actions/install-ci-(collect|run)/ Install-ci composite actions + ^\.github/actions/detect-changes/ Path-gating action + ^\.github/workflows/install-ci\.yml$ This workflow file + ^VERSION$ Version file + (^|/)pyproject\.toml$ Python project metadata + (^|/)environment\.ya?ml$ Conda environment file install-tests-x86: name: Installation Tests (x86) diff --git a/.github/workflows/newton-docker.yml b/.github/workflows/newton-docker.yml index 23bcd6f54524..55730731e81e 100644 --- a/.github/workflows/newton-docker.yml +++ b/.github/workflows/newton-docker.yml @@ -29,104 +29,34 @@ jobs: name: Detect Changes runs-on: ubuntu-latest outputs: - run_newton_tests: ${{ steps.detect.outputs.run_newton_tests }} + run_newton_tests: ${{ steps.detect.outputs.run }} steps: + # Local composite actions need the repo present; only the action + # directory is required. + - uses: actions/checkout@v6 + with: + fetch-depth: 1 + sparse-checkout: .github/actions/detect-changes + sparse-checkout-cone-mode: false + + # This job is an install/build test, so it gates only on the install + # inputs — the same set that feeds the deps-hash in the + # ecr-build-push-pull action — plus its own wiring. Source-level + # kitless regressions are accepted to surface on the next + # install-input change rather than on every source PR. - id: detect - env: - GH_TOKEN: ${{ github.token }} - PR_NUMBER: ${{ github.event.pull_request.number }} - EVENT_NAME: ${{ github.event_name }} - REPO: ${{ github.repository }} - run: | - set -euo pipefail - - # This job is an install/build test, so it gates only on the install - # inputs — the same set that feeds the deps-hash in the - # ecr-build-push-pull action — plus its own wiring. Source-level - # kitless regressions are accepted to surface on the next - # install-input change rather than on every source PR. - # - # The build job skips via `if:` and reports green to branch - # protection, which is why there is no workflow-level `paths:` filter - # (a not-triggered required check would block the PR forever). - patterns=( - $'^docker/Dockerfile\\.newton$\tNewton image definition' - $'^isaaclab\\.sh$\tInstall CLI wrapper' - $'^pyproject\\.toml$\tRoot dependency manifest' - $'^source/[^/]+/pyproject\\.toml$\tPackage manifests' - $'^source/isaaclab/isaaclab/cli/\tInstall CLI implementation' - $'^\\.github/workflows/newton-docker\\.yml$\tThis workflow file' - $'^\\.github/actions/ecr-build-push-pull/\tECR build-cache action' - ) - - render_table() { - local files="$1" entry regex desc count sample shown - echo "| Pattern | What it covers | Matched files |" - echo "|---|---|---|" - for entry in "${patterns[@]}"; do - IFS=$'\t' read -r regex desc <<< "$entry" - # escape | so it doesn't end the markdown table cell mid-regex - shown="${regex//|/\\|}" - count=$(grep -cE "$regex" <<< "$files" || true) - if [ "$count" -gt 0 ]; then - sample=$(grep -m 3 -E "$regex" <<< "$files" | paste -sd ', ' -) - [ "$count" -gt 3 ] && sample="$sample (and $((count - 3)) more)" - echo "| \`$shown\` | $desc | $sample |" - else - echo "| \`$shown\` | $desc | - |" - fi - done - } - - any_match() { - local files="$1" entry regex - for entry in "${patterns[@]}"; do - IFS=$'\t' read -r regex _ <<< "$entry" - if grep -qE "$regex" <<< "$files"; then - return 0 - fi - done - return 1 - } - - decide() { - local decision="$1" reason="$2" files="${3:-}" - echo "Decision: run_newton_tests=$decision ($reason)" - echo "run_newton_tests=$decision" >> "$GITHUB_OUTPUT" - { - echo "## Newton docker gating" - echo "" - if [ "$decision" = "true" ]; then - echo "The Newton docker build and smoke will **run**: $reason." - else - echo "The Newton docker build and smoke will be **skipped**: $reason." - fi - if [ -n "$files" ]; then - echo "" - render_table "$files" - fi - } >> "$GITHUB_STEP_SUMMARY" - } - - if [ "$EVENT_NAME" != "pull_request" ]; then - decide true "non-PR event ($EVENT_NAME)" - exit 0 - fi - - if ! changed_files="$(gh api --paginate "repos/$REPO/pulls/$PR_NUMBER/files" --jq '.[].filename')"; then - # Fail-safe: a transient API error must not block merge. Default to running. - echo "::warning::Could not list changed files; defaulting to running tests" - decide true "fail-safe (could not list changed files)" - exit 0 - fi - - printf '%s\n' "$changed_files" - - if any_match "$changed_files"; then - decide true "relevant paths changed" "$changed_files" - else - decide false "no relevant paths changed" "$changed_files" - fi + uses: ./.github/actions/detect-changes + with: + summary-title: Newton docker gating + patterns: | + ^docker/Dockerfile\.newton$ Newton image definition + ^isaaclab\.sh$ Install CLI wrapper + ^pyproject\.toml$ Root dependency manifest + ^source/[^/]+/pyproject\.toml$ Package manifests + ^source/isaaclab/isaaclab/cli/ Install CLI implementation + ^\.github/workflows/newton-docker\.yml$ This workflow file + ^\.github/actions/ecr-build-push-pull/ ECR build-cache action + ^\.github/actions/detect-changes/ Path-gating action build-newton: name: Build Newton Kitless Docker Image From b3fbaa4fef4b5eb2b5b01ec7076a0412931e3751 Mon Sep 17 00:00:00 2001 From: jichuanh Date: Fri, 10 Jul 2026 21:19:03 +0000 Subject: [PATCH 13/13] Rework the shared gate as a reusable workflow The composite-action form required each caller to add a checkout step, since local ./ actions resolve against the runner workspace. A workflow_call reusable workflow is fetched server-side from the caller's own commit, so the gate job keeps its original shape: a single API-only step with no checkout. Callers shrink to a uses: job with the patterns input; consumers reference the generic run output instead of the per-workflow output names (five mechanical renames). The gate check is now reported as 'Detect Changes / Detect Changes'; it is not a required check, so branch protection is unaffected. --- .github/actions/detect-changes/action.yml | 137 -------------------- .github/workflows/build.yaml | 52 +++----- .github/workflows/detect-changes.yml | 144 ++++++++++++++++++++++ .github/workflows/install-ci.yml | 47 +++---- .github/workflows/newton-docker.yml | 39 ++---- 5 files changed, 194 insertions(+), 225 deletions(-) delete mode 100644 .github/actions/detect-changes/action.yml create mode 100644 .github/workflows/detect-changes.yml diff --git a/.github/actions/detect-changes/action.yml b/.github/actions/detect-changes/action.yml deleted file mode 100644 index 58a36fc111a2..000000000000 --- a/.github/actions/detect-changes/action.yml +++ /dev/null @@ -1,137 +0,0 @@ -# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). -# All rights reserved. -# -# SPDX-License-Identifier: BSD-3-Clause - -name: 'Detect Changed Paths' -description: > - Decides whether path-gated jobs should run for the current event. Pull - requests run the gated jobs only when a changed file matches one of the - given patterns; non-PR events and changed-file listing failures fail open - (run). Writes a gating table to the step summary. Consuming jobs should - skip via `if:` so required checks report green instead of never - triggering (a workflow-level `paths:` filter would leave a required - check pending forever). - -inputs: - patterns: - description: > - One entry per line: an extended regex matched against the changed - file paths, then whitespace, then a human-readable description for - the step-summary table. The regex itself must not contain - whitespace (the first whitespace run is the column separator). - required: true - summary-title: - description: 'Heading for the step-summary section.' - required: false - default: 'Path gating' - summary-note: - description: 'Optional extra line appended below the decision.' - required: false - default: '' - -outputs: - run: - description: '"true" when the gated jobs should run, "false" otherwise.' - value: ${{ steps.detect.outputs.run }} - -runs: - using: composite - steps: - - id: detect - shell: bash - env: - GH_TOKEN: ${{ github.token }} - PR_NUMBER: ${{ github.event.pull_request.number }} - EVENT_NAME: ${{ github.event_name }} - REPO: ${{ github.repository }} - PATTERNS_INPUT: ${{ inputs.patterns }} - SUMMARY_TITLE: ${{ inputs.summary-title }} - SUMMARY_NOTE: ${{ inputs.summary-note }} - run: | - set -euo pipefail - - # Parse "regexdescription" lines into parallel arrays. - regexes=() - descriptions=() - while IFS= read -r line; do - [[ -z "${line//[[:space:]]/}" ]] && continue - regex="${line%%[[:space:]]*}" - desc="${line#"$regex"}" - desc="${desc#"${desc%%[![:space:]]*}"}" - regexes+=("$regex") - descriptions+=("${desc:-$regex}") - done <<< "$PATTERNS_INPUT" - - render_table() { - local files="$1" i regex desc count sample shown - echo "| Pattern | What it covers | Matched files |" - echo "|---|---|---|" - for i in "${!regexes[@]}"; do - regex="${regexes[$i]}" - desc="${descriptions[$i]}" - # escape | so it doesn't end the markdown table cell mid-regex - shown="${regex//|/\\|}" - count=$(grep -cE "$regex" <<< "$files" || true) - if [ "$count" -gt 0 ]; then - sample=$(grep -m 3 -E "$regex" <<< "$files" | paste -sd ', ' -) - [ "$count" -gt 3 ] && sample="$sample (and $((count - 3)) more)" - echo "| \`$shown\` | $desc | $sample |" - else - echo "| \`$shown\` | $desc | - |" - fi - done - } - - any_match() { - local files="$1" regex - for regex in "${regexes[@]}"; do - if grep -qE "$regex" <<< "$files"; then - return 0 - fi - done - return 1 - } - - decide() { - local decision="$1" reason="$2" files="${3:-}" - echo "Decision: run=$decision ($reason)" - echo "run=$decision" >> "$GITHUB_OUTPUT" - { - echo "## ${SUMMARY_TITLE}" - echo "" - if [ "$decision" = "true" ]; then - echo "Gated jobs will **run**: $reason." - else - echo "Gated jobs will be **skipped**: $reason." - fi - if [ -n "$SUMMARY_NOTE" ]; then - echo "" - echo "$SUMMARY_NOTE" - fi - if [ -n "$files" ]; then - echo "" - render_table "$files" - fi - } >> "$GITHUB_STEP_SUMMARY" - } - - if [ "$EVENT_NAME" != "pull_request" ]; then - decide true "non-PR event ($EVENT_NAME)" - exit 0 - fi - - if ! changed_files="$(gh api --paginate "repos/$REPO/pulls/$PR_NUMBER/files" --jq '.[].filename')"; then - # Fail-safe: a transient API error must not block merge. Default to running. - echo "::warning::Could not list changed files; defaulting to running tests" - decide true "fail-safe (could not list changed files)" - exit 0 - fi - - printf '%s\n' "$changed_files" - - if any_match "$changed_files"; then - decide true "relevant paths changed" "$changed_files" - else - decide false "no relevant paths changed" "$changed_files" - fi diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index bf6e9d249dad..d477cbc3aee5 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -77,39 +77,27 @@ env: jobs: changes: name: Detect Changes - runs-on: ubuntu-latest - outputs: - run_docker_tests: ${{ steps.detect.outputs.run }} - steps: - # Local composite actions need the repo present; only the action - # directory is required. - - uses: actions/checkout@v6 - with: - fetch-depth: 1 - sparse-checkout: .github/actions/detect-changes - sparse-checkout-cone-mode: false - # Docker test jobs run only when paths in the patterns table change. # config.yaml is included because it controls the base image names and # tags consumed by the Docker build jobs. - - id: detect - uses: ./.github/actions/detect-changes - with: - summary-title: Docker test gating - summary-note: >- - Triggered jobs: ${{ github.event_name == 'push' - && 'Docker base build job + rendering-correctness + rendering-correctness-kitless' - || 'Docker build jobs + all test-* matrix jobs' }}. - patterns: | - ^source/ Library source code - ^docker/ Container build inputs - ^tools/ Build tooling - ^apps/ Standalone apps - ^scripts/ Standalone scripts - ^\.github/workflows/build\.yaml$ This workflow file - ^\.github/workflows/config\.yaml$ Base image config - ^\.github/actions/ CI actions - ^\.github/test-subsets/ CI test subset config + uses: ./.github/workflows/detect-changes.yml + with: + summary-title: Docker test gating + summary-note: >- + Triggered jobs: ${{ github.event_name == 'push' + && 'Docker base build job + rendering-correctness + rendering-correctness-kitless' + || 'Docker build jobs + all test-* matrix jobs' }}. + patterns: | + ^source/ Library source code + ^docker/ Container build inputs + ^tools/ Build tooling + ^apps/ Standalone apps + ^scripts/ Standalone scripts + ^\.github/workflows/build\.yaml$ This workflow file + ^\.github/workflows/config\.yaml$ Base image config + ^\.github/workflows/detect-changes\.yml$ Path-gating workflow + ^\.github/actions/ CI actions + ^\.github/test-subsets/ CI test subset config config: name: Load Config @@ -160,7 +148,7 @@ jobs: name: Build Base Docker Image runs-on: [self-hosted, gpu] needs: [changes, config] - if: needs.changes.outputs.run_docker_tests == 'true' + if: needs.changes.outputs.run == 'true' steps: - name: Checkout Code uses: actions/checkout@v6 @@ -183,7 +171,7 @@ jobs: needs: [changes, config] if: >- github.event_name != 'push' && - needs.changes.outputs.run_docker_tests == 'true' + needs.changes.outputs.run == 'true' steps: - name: Checkout Code uses: actions/checkout@v6 diff --git a/.github/workflows/detect-changes.yml b/.github/workflows/detect-changes.yml new file mode 100644 index 000000000000..89a905c9eb9d --- /dev/null +++ b/.github/workflows/detect-changes.yml @@ -0,0 +1,144 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +# Shared path-gating engine, called via workflow_call so the gate job needs +# no checkout: GitHub resolves local reusable workflows server-side from the +# caller's own commit. Pull requests run the gated jobs only when a changed +# file matches one of the given patterns; non-PR events and changed-file +# listing failures fail open (run). Consuming jobs should skip via `if:` so +# required checks report green instead of never triggering (a workflow-level +# `paths:` filter would leave a required check pending forever). + +name: Detect Changed Paths + +on: + workflow_call: + inputs: + patterns: + description: > + One entry per line: an extended regex matched against the changed + file paths, then whitespace, then a human-readable description for + the step-summary table. The regex itself must not contain + whitespace (the first whitespace run is the column separator). + required: true + type: string + summary-title: + description: 'Heading for the step-summary section.' + required: false + type: string + default: 'Path gating' + summary-note: + description: 'Optional extra line appended below the decision.' + required: false + type: string + default: '' + outputs: + run: + description: '"true" when the gated jobs should run, "false" otherwise.' + value: ${{ jobs.detect.outputs.run }} + +jobs: + detect: + name: Detect Changes + runs-on: ubuntu-latest + outputs: + run: ${{ steps.detect.outputs.run }} + steps: + - id: detect + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + EVENT_NAME: ${{ github.event_name }} + REPO: ${{ github.repository }} + PATTERNS_INPUT: ${{ inputs.patterns }} + SUMMARY_TITLE: ${{ inputs.summary-title }} + SUMMARY_NOTE: ${{ inputs.summary-note }} + run: | + set -euo pipefail + + # Parse "regexdescription" lines into parallel arrays. + regexes=() + descriptions=() + while IFS= read -r line; do + [[ -z "${line//[[:space:]]/}" ]] && continue + regex="${line%%[[:space:]]*}" + desc="${line#"$regex"}" + desc="${desc#"${desc%%[![:space:]]*}"}" + regexes+=("$regex") + descriptions+=("${desc:-$regex}") + done <<< "$PATTERNS_INPUT" + + render_table() { + local files="$1" i regex desc count sample shown + echo "| Pattern | What it covers | Matched files |" + echo "|---|---|---|" + for i in "${!regexes[@]}"; do + regex="${regexes[$i]}" + desc="${descriptions[$i]}" + # escape | so it doesn't end the markdown table cell mid-regex + shown="${regex//|/\\|}" + count=$(grep -cE "$regex" <<< "$files" || true) + if [ "$count" -gt 0 ]; then + sample=$(grep -m 3 -E "$regex" <<< "$files" | paste -sd ', ' -) + [ "$count" -gt 3 ] && sample="$sample (and $((count - 3)) more)" + echo "| \`$shown\` | $desc | $sample |" + else + echo "| \`$shown\` | $desc | - |" + fi + done + } + + any_match() { + local files="$1" regex + for regex in "${regexes[@]}"; do + if grep -qE "$regex" <<< "$files"; then + return 0 + fi + done + return 1 + } + + decide() { + local decision="$1" reason="$2" files="${3:-}" + echo "Decision: run=$decision ($reason)" + echo "run=$decision" >> "$GITHUB_OUTPUT" + { + echo "## ${SUMMARY_TITLE}" + echo "" + if [ "$decision" = "true" ]; then + echo "Gated jobs will **run**: $reason." + else + echo "Gated jobs will be **skipped**: $reason." + fi + if [ -n "$SUMMARY_NOTE" ]; then + echo "" + echo "$SUMMARY_NOTE" + fi + if [ -n "$files" ]; then + echo "" + render_table "$files" + fi + } >> "$GITHUB_STEP_SUMMARY" + } + + if [ "$EVENT_NAME" != "pull_request" ]; then + decide true "non-PR event ($EVENT_NAME)" + exit 0 + fi + + if ! changed_files="$(gh api --paginate "repos/$REPO/pulls/$PR_NUMBER/files" --jq '.[].filename')"; then + # Fail-safe: a transient API error must not block merge. Default to running. + echo "::warning::Could not list changed files; defaulting to running tests" + decide true "fail-safe (could not list changed files)" + exit 0 + fi + + printf '%s\n' "$changed_files" + + if any_match "$changed_files"; then + decide true "relevant paths changed" "$changed_files" + else + decide false "no relevant paths changed" "$changed_files" + fi diff --git a/.github/workflows/install-ci.yml b/.github/workflows/install-ci.yml index 15db0a3e8467..faa25b569470 100644 --- a/.github/workflows/install-ci.yml +++ b/.github/workflows/install-ci.yml @@ -34,39 +34,26 @@ permissions: jobs: changes: name: Detect Changes - runs-on: ubuntu-latest - outputs: - run_install_tests: ${{ steps.detect.outputs.run }} - steps: - # Local composite actions need the repo present; only the action - # directory is required. - - uses: actions/checkout@v6 - with: - fetch-depth: 1 - sparse-checkout: .github/actions/detect-changes - sparse-checkout-cone-mode: false - - # Installation tests run only when paths in the patterns table change. - - id: detect - uses: ./.github/actions/detect-changes - with: - summary-title: Installation test gating - patterns: | - ^apps/ Standalone apps - ^tools/ Build tooling - ^source/ Library source code - ^\.github/actions/run-package-tests/ Test action - ^\.github/actions/install-ci-(collect|run)/ Install-ci composite actions - ^\.github/actions/detect-changes/ Path-gating action - ^\.github/workflows/install-ci\.yml$ This workflow file - ^VERSION$ Version file - (^|/)pyproject\.toml$ Python project metadata - (^|/)environment\.ya?ml$ Conda environment file + # Installation tests run only when paths in the patterns table change. + uses: ./.github/workflows/detect-changes.yml + with: + summary-title: Installation test gating + patterns: | + ^apps/ Standalone apps + ^tools/ Build tooling + ^source/ Library source code + ^\.github/actions/run-package-tests/ Test action + ^\.github/actions/install-ci-(collect|run)/ Install-ci composite actions + ^\.github/workflows/detect-changes\.yml$ Path-gating workflow + ^\.github/workflows/install-ci\.yml$ This workflow file + ^VERSION$ Version file + (^|/)pyproject\.toml$ Python project metadata + (^|/)environment\.ya?ml$ Conda environment file install-tests-x86: name: Installation Tests (x86) needs: [changes] - if: needs.changes.outputs.run_install_tests == 'true' + if: needs.changes.outputs.run == 'true' runs-on: [self-hosted, gpu] timeout-minutes: 90 steps: @@ -85,7 +72,7 @@ jobs: install-tests-arm: name: Installation Tests (ARM) needs: [changes] - if: needs.changes.outputs.run_install_tests == 'true' + if: needs.changes.outputs.run == 'true' runs-on: [self-hosted, aarch64] timeout-minutes: 90 steps: diff --git a/.github/workflows/newton-docker.yml b/.github/workflows/newton-docker.yml index 55730731e81e..8c99c5aeedd6 100644 --- a/.github/workflows/newton-docker.yml +++ b/.github/workflows/newton-docker.yml @@ -27,43 +27,30 @@ permissions: jobs: changes: name: Detect Changes - runs-on: ubuntu-latest - outputs: - run_newton_tests: ${{ steps.detect.outputs.run }} - steps: - # Local composite actions need the repo present; only the action - # directory is required. - - uses: actions/checkout@v6 - with: - fetch-depth: 1 - sparse-checkout: .github/actions/detect-changes - sparse-checkout-cone-mode: false - # This job is an install/build test, so it gates only on the install # inputs — the same set that feeds the deps-hash in the # ecr-build-push-pull action — plus its own wiring. Source-level # kitless regressions are accepted to surface on the next # install-input change rather than on every source PR. - - id: detect - uses: ./.github/actions/detect-changes - with: - summary-title: Newton docker gating - patterns: | - ^docker/Dockerfile\.newton$ Newton image definition - ^isaaclab\.sh$ Install CLI wrapper - ^pyproject\.toml$ Root dependency manifest - ^source/[^/]+/pyproject\.toml$ Package manifests - ^source/isaaclab/isaaclab/cli/ Install CLI implementation - ^\.github/workflows/newton-docker\.yml$ This workflow file - ^\.github/actions/ecr-build-push-pull/ ECR build-cache action - ^\.github/actions/detect-changes/ Path-gating action + uses: ./.github/workflows/detect-changes.yml + with: + summary-title: Newton docker gating + patterns: | + ^docker/Dockerfile\.newton$ Newton image definition + ^isaaclab\.sh$ Install CLI wrapper + ^pyproject\.toml$ Root dependency manifest + ^source/[^/]+/pyproject\.toml$ Package manifests + ^source/isaaclab/isaaclab/cli/ Install CLI implementation + ^\.github/workflows/newton-docker\.yml$ This workflow file + ^\.github/workflows/detect-changes\.yml$ Path-gating workflow + ^\.github/actions/ecr-build-push-pull/ ECR build-cache action build-newton: name: Build Newton Kitless Docker Image runs-on: [self-hosted, gpu] timeout-minutes: 120 needs: [changes] - if: needs.changes.outputs.run_newton_tests == 'true' + if: needs.changes.outputs.run == 'true' steps: - name: Checkout Code uses: actions/checkout@v6