From 8492b326e5f06f8bd873f766833da2103f78f9fb Mon Sep 17 00:00:00 2001 From: Russ Tedrake Date: Mon, 7 Sep 2026 18:11:05 -0400 Subject: [PATCH 1/9] Cache CI dependency downloads and record test timings --- .github/workflows/main.yml | 78 +++++++++++++++++++++++++++++++++++--- docs/testing.md | 15 ++++++++ 2 files changed, 88 insertions(+), 5 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 9105aea8..55aee7e9 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -102,7 +102,7 @@ jobs: run: | export APT_KEY_DONT_WARN_ON_DANGEROUS_USAGE=1 DEBIAN_FRONTEND=noninteractive apt-get update -o APT::Acquire::Retries=4 -qq - apt-get install -o APT::Acquire::Retries=4 -o Dpkg::Use-Pty=0 -qy --no-install-recommends ca-certificates gnupg sudo + apt-get install -o APT::Acquire::Retries=4 -o Dpkg::Use-Pty=0 -qy --no-install-recommends ca-certificates gnupg sudo zstd apt-key adv --keyserver keyserver.ubuntu.com --recv-keys E1DD270288B4E6030699E45FA1715D88E1DF1F24 echo 'deb http://ppa.launchpad.net/git-core/ppa/ubuntu noble main' > /etc/apt/sources.list.d/git.list apt-get update -o APT::Acquire::Retries=4 -qq @@ -141,8 +141,23 @@ jobs: cd solutions && git checkout $(cat ../solutions_sha.txt) && cd .. shell: bash + # pull_request_target runs use the base branch cache scope; keep them uncached. + - name: cache Python downloads + if: github.event_name != 'pull_request_target' + uses: actions/cache@v5 + with: + path: | + /home/runner/.cache/pip + /home/runner/.cache/pypoetry + key: python-downloads-v1-${{ runner.os }}-${{ runner.arch }}-noble-poetry-${{ hashFiles('poetry.lock', 'pyproject.toml', '.github/workflows/main.yml') }} + restore-keys: | + python-downloads-v1-${{ runner.os }}-${{ runner.arch }}-noble-poetry- + - name: install dependencies run: | + # Cache actions run as root; pip / Poetry run as runner. + mkdir -p /home/runner/.cache/pip /home/runner/.cache/pypoetry + chown -R runner:runner /home/runner/.cache/pip /home/runner/.cache/pypoetry # Fix ownership of the workspace chown -R runner:runner . # Switch to runner user for remaining commands @@ -166,9 +181,18 @@ jobs: set -euo pipefail VENV_PYTHON="$(pwd)/.venv/bin/python" sudo -H -u runner env -u DISPLAY XDG_CACHE_HOME=/tmp \ - DRAKE_ALLOW_NETWORK=lcm:meshcat "$VENV_PYTHON" -m pytest -ra + DRAKE_ALLOW_NETWORK=lcm:meshcat "$VENV_PYTHON" -m pytest -ra --durations=30 --junitxml=test-results/pytest.xml shell: bash + - name: upload test timings + if: always() + uses: actions/upload-artifact@v4 + with: + name: pytest-${{ github.job }} + path: test-results/pytest.xml + if-no-files-found: ignore + retention-days: 14 + # TODO(russt): add noble-pip-core workflow which only runs the tests in manipulation, # but uses the manipulation module from pip. noble-pip-all: @@ -193,7 +217,7 @@ jobs: run: | export APT_KEY_DONT_WARN_ON_DANGEROUS_USAGE=1 DEBIAN_FRONTEND=noninteractive apt-get update -o APT::Acquire::Retries=4 -qq - apt-get install -o APT::Acquire::Retries=4 -o Dpkg::Use-Pty=0 -qy --no-install-recommends ca-certificates gnupg sudo + apt-get install -o APT::Acquire::Retries=4 -o Dpkg::Use-Pty=0 -qy --no-install-recommends ca-certificates gnupg sudo zstd apt-key adv --keyserver keyserver.ubuntu.com --recv-keys E1DD270288B4E6030699E45FA1715D88E1DF1F24 echo 'deb http://ppa.launchpad.net/git-core/ppa/ubuntu noble main' > /etc/apt/sources.list.d/git.list apt-get update -o APT::Acquire::Retries=4 -qq @@ -232,8 +256,22 @@ jobs: cd solutions && git checkout $(cat ../solutions_sha.txt) && cd .. shell: bash + # pull_request_target runs use the base branch cache scope; keep them uncached. + - name: cache Python downloads + if: github.event_name != 'pull_request_target' + uses: actions/cache@v5 + with: + path: | + /home/runner/.cache/pip + key: python-downloads-v1-${{ runner.os }}-${{ runner.arch }}-noble-pip-all-${{ hashFiles('poetry.lock', 'pyproject.toml', '.github/workflows/main.yml') }} + restore-keys: | + python-downloads-v1-${{ runner.os }}-${{ runner.arch }}-noble-pip-all- + - name: install dependencies run: | + # Cache actions run as root; pip / Poetry run as runner. + mkdir -p /home/runner/.cache/pip + chown -R runner:runner /home/runner/.cache/pip # Fix ownership of the workspace chown -R runner:runner . # Switch to runner user for remaining commands @@ -257,9 +295,18 @@ jobs: find book/htmlbook -type f -name 'test_*.py' -delete # remove htmlbook tests (extra Python requirements) sudo -H -u runner env -u DISPLAY MANIPULATION_DATA_DIR="$(pwd)/book/data" \ XDG_CACHE_HOME=/tmp DRAKE_ALLOW_NETWORK=lcm:meshcat \ - python3 -m pytest -ra + python3 -m pytest -ra --durations=30 --junitxml=test-results/pytest.xml shell: bash + - name: upload test timings + if: always() + uses: actions/upload-artifact@v4 + with: + name: pytest-${{ github.job }} + path: test-results/pytest.xml + if-no-files-found: ignore + retention-days: 14 + sequoia: name: macos sequoia 15 runs-on: macos-15 @@ -308,6 +355,18 @@ jobs: cd solutions && git checkout $(cat ../solutions_sha.txt) && cd .. shell: zsh -efuo pipefail {0} + # pull_request_target runs use the base branch cache scope; keep them uncached. + - name: cache Python downloads + if: github.event_name != 'pull_request_target' + uses: actions/cache@v5 + with: + path: | + ~/Library/Caches/pip + ~/Library/Caches/pypoetry + key: python-downloads-v1-${{ runner.os }}-${{ runner.arch }}-sequoia-${{ hashFiles('poetry.lock', 'pyproject.toml', '.github/workflows/main.yml') }} + restore-keys: | + python-downloads-v1-${{ runner.os }}-${{ runner.arch }}-sequoia- + - name: setup run: | ./setup/submodule_checkout @@ -328,9 +387,18 @@ jobs: - name: test run: | set -euo pipefail - XDG_CACHE_HOME=/tmp DRAKE_ALLOW_NETWORK=lcm:meshcat .venv/bin/python -m pytest -ra + XDG_CACHE_HOME=/tmp DRAKE_ALLOW_NETWORK=lcm:meshcat .venv/bin/python -m pytest -ra --durations=30 --junitxml=test-results/pytest.xml shell: zsh -efuo pipefail {0} + - name: upload test timings + if: always() + uses: actions/upload-artifact@v4 + with: + name: pytest-${{ github.job }} + path: test-results/pytest.xml + if-no-files-found: ignore + retention-days: 14 + lint: runs-on: ubuntu-latest needs: [check-approval] diff --git a/docs/testing.md b/docs/testing.md index a8a877b8..184fdf73 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -27,3 +27,18 @@ by tests; it deliberately excludes models such as Gymnasium Robotics that notebooks only download in interactive mode. See Drake's [network policy documentation](https://drake.mit.edu/doxygen_cxx/group__allow__network.html). + +# CI timings and caches + +Each test job prints its 30 slowest pytest phases and uploads a JUnit report as +`pytest-`, retained for 14 days and uploaded even on test failure when +available. Compare per-test times alongside Actions step timings. + +The three test jobs cache pip/Poetry downloads; installation still runs every +time, including the pip job's upgrade check. Keys include OS, architecture, job, +and dependency/workflow hashes, with older downloads as a fallback. Labeled fork +runs (`pull_request_target`) remain uncached because they use the base branch's +cache scope. Drake packages are still fetched afresh on each runner. + +The first run populates caches. Compare a subsequent run, including cache +transfer time, to decide whether the savings justify keeping them. From fa8b7e903d9c25ea92d8c659c85992a31d4b1060 Mon Sep 17 00:00:00 2001 From: Russ Tedrake Date: Mon, 7 Sep 2026 18:37:24 -0400 Subject: [PATCH 2/9] Keep only the macOS cache after measuring restore overhead --- .github/workflows/main.yml | 33 ++------------------------------- docs/testing.md | 5 +++-- 2 files changed, 5 insertions(+), 33 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 55aee7e9..f4f099cf 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -102,7 +102,7 @@ jobs: run: | export APT_KEY_DONT_WARN_ON_DANGEROUS_USAGE=1 DEBIAN_FRONTEND=noninteractive apt-get update -o APT::Acquire::Retries=4 -qq - apt-get install -o APT::Acquire::Retries=4 -o Dpkg::Use-Pty=0 -qy --no-install-recommends ca-certificates gnupg sudo zstd + apt-get install -o APT::Acquire::Retries=4 -o Dpkg::Use-Pty=0 -qy --no-install-recommends ca-certificates gnupg sudo apt-key adv --keyserver keyserver.ubuntu.com --recv-keys E1DD270288B4E6030699E45FA1715D88E1DF1F24 echo 'deb http://ppa.launchpad.net/git-core/ppa/ubuntu noble main' > /etc/apt/sources.list.d/git.list apt-get update -o APT::Acquire::Retries=4 -qq @@ -141,23 +141,8 @@ jobs: cd solutions && git checkout $(cat ../solutions_sha.txt) && cd .. shell: bash - # pull_request_target runs use the base branch cache scope; keep them uncached. - - name: cache Python downloads - if: github.event_name != 'pull_request_target' - uses: actions/cache@v5 - with: - path: | - /home/runner/.cache/pip - /home/runner/.cache/pypoetry - key: python-downloads-v1-${{ runner.os }}-${{ runner.arch }}-noble-poetry-${{ hashFiles('poetry.lock', 'pyproject.toml', '.github/workflows/main.yml') }} - restore-keys: | - python-downloads-v1-${{ runner.os }}-${{ runner.arch }}-noble-poetry- - - name: install dependencies run: | - # Cache actions run as root; pip / Poetry run as runner. - mkdir -p /home/runner/.cache/pip /home/runner/.cache/pypoetry - chown -R runner:runner /home/runner/.cache/pip /home/runner/.cache/pypoetry # Fix ownership of the workspace chown -R runner:runner . # Switch to runner user for remaining commands @@ -217,7 +202,7 @@ jobs: run: | export APT_KEY_DONT_WARN_ON_DANGEROUS_USAGE=1 DEBIAN_FRONTEND=noninteractive apt-get update -o APT::Acquire::Retries=4 -qq - apt-get install -o APT::Acquire::Retries=4 -o Dpkg::Use-Pty=0 -qy --no-install-recommends ca-certificates gnupg sudo zstd + apt-get install -o APT::Acquire::Retries=4 -o Dpkg::Use-Pty=0 -qy --no-install-recommends ca-certificates gnupg sudo apt-key adv --keyserver keyserver.ubuntu.com --recv-keys E1DD270288B4E6030699E45FA1715D88E1DF1F24 echo 'deb http://ppa.launchpad.net/git-core/ppa/ubuntu noble main' > /etc/apt/sources.list.d/git.list apt-get update -o APT::Acquire::Retries=4 -qq @@ -256,22 +241,8 @@ jobs: cd solutions && git checkout $(cat ../solutions_sha.txt) && cd .. shell: bash - # pull_request_target runs use the base branch cache scope; keep them uncached. - - name: cache Python downloads - if: github.event_name != 'pull_request_target' - uses: actions/cache@v5 - with: - path: | - /home/runner/.cache/pip - key: python-downloads-v1-${{ runner.os }}-${{ runner.arch }}-noble-pip-all-${{ hashFiles('poetry.lock', 'pyproject.toml', '.github/workflows/main.yml') }} - restore-keys: | - python-downloads-v1-${{ runner.os }}-${{ runner.arch }}-noble-pip-all- - - name: install dependencies run: | - # Cache actions run as root; pip / Poetry run as runner. - mkdir -p /home/runner/.cache/pip - chown -R runner:runner /home/runner/.cache/pip # Fix ownership of the workspace chown -R runner:runner . # Switch to runner user for remaining commands diff --git a/docs/testing.md b/docs/testing.md index 184fdf73..ba842290 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -34,8 +34,9 @@ Each test job prints its 30 slowest pytest phases and uploads a JUnit report as `pytest-`, retained for 14 days and uploaded even on test failure when available. Compare per-test times alongside Actions step timings. -The three test jobs cache pip/Poetry downloads; installation still runs every -time, including the pip job's upgrade check. Keys include OS, architecture, job, +The macOS job caches pip/Poetry downloads; installation still runs every time. +Linux download caches are omitted: measured archive restoration cost more than +the installation time they saved. Keys include OS, architecture, job, and dependency/workflow hashes, with older downloads as a fallback. Labeled fork runs (`pull_request_target`) remain uncached because they use the base branch's cache scope. Drake packages are still fetched afresh on each runner. From 5cd72c7e77221d39936c237e6c141db3297482d7 Mon Sep 17 00:00:00 2001 From: Russ Tedrake Date: Mon, 7 Sep 2026 18:41:16 -0400 Subject: [PATCH 3/9] Keep timing instrumentation; drop caches without a repeatable gain --- .github/workflows/main.yml | 12 ------------ docs/testing.md | 18 +++++++----------- 2 files changed, 7 insertions(+), 23 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index f4f099cf..38af4c36 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -326,18 +326,6 @@ jobs: cd solutions && git checkout $(cat ../solutions_sha.txt) && cd .. shell: zsh -efuo pipefail {0} - # pull_request_target runs use the base branch cache scope; keep them uncached. - - name: cache Python downloads - if: github.event_name != 'pull_request_target' - uses: actions/cache@v5 - with: - path: | - ~/Library/Caches/pip - ~/Library/Caches/pypoetry - key: python-downloads-v1-${{ runner.os }}-${{ runner.arch }}-sequoia-${{ hashFiles('poetry.lock', 'pyproject.toml', '.github/workflows/main.yml') }} - restore-keys: | - python-downloads-v1-${{ runner.os }}-${{ runner.arch }}-sequoia- - - name: setup run: | ./setup/submodule_checkout diff --git a/docs/testing.md b/docs/testing.md index ba842290..2d35650e 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -28,18 +28,14 @@ notebooks only download in interactive mode. See Drake's [network policy documentation](https://drake.mit.edu/doxygen_cxx/group__allow__network.html). -# CI timings and caches +# CI timings Each test job prints its 30 slowest pytest phases and uploads a JUnit report as `pytest-`, retained for 14 days and uploaded even on test failure when -available. Compare per-test times alongside Actions step timings. +available. Compare per-test times alongside Actions step timings. Test execution +remains serial. -The macOS job caches pip/Poetry downloads; installation still runs every time. -Linux download caches are omitted: measured archive restoration cost more than -the installation time they saved. Keys include OS, architecture, job, -and dependency/workflow hashes, with older downloads as a fallback. Labeled fork -runs (`pull_request_target`) remain uncached because they use the base branch's -cache scope. Drake packages are still fetched afresh on each runner. - -The first run populates caches. Compare a subsequent run, including cache -transfer time, to decide whether the savings justify keeping them. +Download caching was evaluated in CI, including archive restoration overhead. +Linux restoration cost more than the installation time it saved, and macOS did +not show a consistent setup improvement. The workflow therefore keeps fresh +dependency installation and model prefetch without download cache actions. From fb69400d8278a12af4486f00ef93fe6b9aa46e91 Mon Sep 17 00:00:00 2001 From: Russ Tedrake Date: Mon, 7 Sep 2026 21:20:00 -0400 Subject: [PATCH 4/9] Focus letter and Menagerie tests on representative coverage --- docs/testing.md | 20 ++++ manipulation/test/test_letter_generation.py | 61 ++++------ .../test/test_make_drake_compatible_model.py | 108 +++++++++++------- 3 files changed, 109 insertions(+), 80 deletions(-) diff --git a/docs/testing.md b/docs/testing.md index 2d35650e..3d9e6bcd 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -39,3 +39,23 @@ Download caching was evaluated in CI, including archive restoration overhead. Linux restoration cost more than the installation time it saved, and macOS did not show a consistent setup improvement. The workflow therefore keeps fresh dependency installation and model prefetch without download cache actions. + +# Menagerie conversion coverage + +The default conversion test uses Panda (STL meshes, includes, and defaults) and +ANYmal B (textures and materials). It copies each model to a temporary directory, +converts its scene, checks mesh references, and loads the result with Drake. +[Drake's Menagerie tests](https://github.com/RobotLocomotion/drake/blob/master/multibody/parsing/test/detail_mujoco_parser_examples_test.cc) +exercise raw MJCF parsing, not this conversion code. + +When updating the Menagerie revision in `manipulation/remotes.py`, run the full +conversion sweep locally: + +```sh +TEST_ALL_MENAGERIE=1 .venv/bin/python -m pytest \ + manipulation/test/test_make_drake_compatible_model.py -k mujoco_menagerie +``` + +This checks conversion for all matching scenes. Asset references and Drake loading +are asserted for the two representatives; other upstream scenes can contain +dangling material references or use unsupported MJCF features. Generated files stay in temporary directories, outside the model cache. diff --git a/manipulation/test/test_letter_generation.py b/manipulation/test/test_letter_generation.py index 3f7f67b1..b97510da 100644 --- a/manipulation/test/test_letter_generation.py +++ b/manipulation/test/test_letter_generation.py @@ -99,7 +99,9 @@ def test_create_sdf_asset_different_fonts(self): font_name=font, letter_height_meters=0.4, extrusion_depth_meters=0.15, - output_dir=self._tmp_dir, + output_dir=f"{self._tmp_dir}/{font}", + # Exercise font geometry without repeating CoACD coverage. + use_bbox_collision_geometry=True, ) self.assertIsNotNone( @@ -114,54 +116,35 @@ def test_create_sdf_asset_different_parameters(self): """Test SDF asset creation with different parameters.""" letter = "C" - # Test with different letter heights - for letter_height in [0.2, 0.4, 0.6]: - with self.subTest(letter_height=letter_height): + dimensions = [(height, 0.15) for height in (0.2, 0.4, 0.6)] + dimensions += [(0.4, depth) for depth in (0.1, 0.2)] + for height, depth in dimensions: + with self.subTest(height=height, depth=depth): sdf_path = create_sdf_asset_from_letter( text=letter, font_name="DejaVu Sans", - letter_height_meters=letter_height, - extrusion_depth_meters=0.15, - output_dir=self._tmp_dir, - ) - - self.assertIsNotNone( - sdf_path, - f"SDF path should not be None for letter_height {letter_height}", - ) - if sdf_path: - self.assertTrue( - sdf_path.exists(), - f"SDF file should exist for letter_height {letter_height}", - ) - - # Test with different extrusion depths - for depth in [0.1, 0.15, 0.2]: - with self.subTest(extrusion_depth=depth): - sdf_path = create_sdf_asset_from_letter( - text=letter, - font_name="DejaVu Sans", - letter_height_meters=0.4, + letter_height_meters=height, extrusion_depth_meters=depth, - output_dir=self._tmp_dir, - ) - - self.assertIsNotNone( - sdf_path, f"SDF path should not be None for depth {depth}" + output_dir=f"{self._tmp_dir}/dimensions_{height}_{depth}", + use_bbox_collision_geometry=True, ) - if sdf_path: - self.assertTrue( - sdf_path.exists(), f"SDF file should exist for depth {depth}" - ) - + self.assertIsNotNone(sdf_path) + self.assertTrue(sdf_path.exists()) + mesh = trimesh.load_mesh(sdf_path.with_suffix(".obj")) + self.assertAlmostEqual(mesh.extents[1], height) + self.assertAlmostEqual(mesh.extents[2], depth) + + def test_create_sdf_asset_collision_geometry(self): + """Exercise both collision methods independently of dimension tests.""" + letter = "C" for use_bbox_collision_geometry in [True, False]: - with self.subTest(extrusion_depth=depth): + with self.subTest(use_bbox_collision_geometry=use_bbox_collision_geometry): sdf_path = create_sdf_asset_from_letter( text=letter, font_name="DejaVu Sans", letter_height_meters=0.4, - extrusion_depth_meters=depth, - output_dir=self._tmp_dir, + extrusion_depth_meters=0.15, + output_dir=f"{self._tmp_dir}/collision_{use_bbox_collision_geometry}", use_bbox_collision_geometry=use_bbox_collision_geometry, ) diff --git a/manipulation/test/test_make_drake_compatible_model.py b/manipulation/test/test_make_drake_compatible_model.py index f1075de6..649fc26a 100644 --- a/manipulation/test/test_make_drake_compatible_model.py +++ b/manipulation/test/test_make_drake_compatible_model.py @@ -1,10 +1,13 @@ import os -import re +import shutil import tempfile import unittest +from pathlib import Path from lxml import etree -from pydrake.multibody.parsing import PackageMap +from pydrake.multibody.parsing import PackageMap, Parser +from pydrake.multibody.plant import AddMultibodyPlantSceneGraph +from pydrake.systems.framework import DiagramBuilder from manipulation.utils import FindResource @@ -173,48 +176,71 @@ def test_mjcf_defaults(self): os.remove(output_filename) def test_mujoco_menagerie(self): - """Test all files in the mujoco_menagerie package.""" + """Cover our conversion pipeline; Drake tests raw Menagerie parsing.""" package_map = PackageMap() AddMujocoMenagerie(package_map) - menagerie = package_map.GetPath("mujoco_menagerie") - # Find all XML files recursively under the menagerie path - results = "" - for root, dirs, files in os.walk(menagerie): - for file in files: - if file.endswith(".drake.xml"): + menagerie = Path(package_map.GetPath("mujoco_menagerie")) + representative_scenes = ( + "franka_emika_panda/scene.xml", # STL meshes, includes, defaults. + "anybotics_anymal_b/scene.xml", # File-backed textures and materials. + ) + scenes = [menagerie / scene for scene in representative_scenes] + if os.environ.get("TEST_ALL_MENAGERIE") == "1": + scenes = sorted(menagerie.rglob("*scene.xml")) + self.assertTrue(scenes) + for scene in scenes: + relative_scene = scene.relative_to(menagerie).as_posix() + with self.subTest( + scene=relative_scene + ), tempfile.TemporaryDirectory() as tmp: + # Keep generated meshes out of the shared package cache. Recompute + # outputs even if an older local run left converted assets there. + model_dir = Path(tmp) / scene.parent.name + shutil.copytree(scene.parent, model_dir) + output = model_dir / "scene.drake.xml" + MakeDrakeCompatibleModel( + str(model_dir / scene.name), str(output), overwrite=True + ) + self.assertTrue(output.is_file()) + # Preserve the full sweep's conversion-smoke-test contract. + # Some other upstream OBJ files have dangling material references. + if relative_scene not in representative_scenes: continue - if file.endswith("scene.xml"): - with self.subTest(file=file): - original_file = os.path.join(root, file) - drake_compatible_file = original_file.replace( - ".xml", ".drake.xml" - ) - try: - MakeDrakeCompatibleModel( - original_file, drake_compatible_file - ) - results += ( - f"PASS: {os.path.relpath(root, menagerie)}/{file}\n" - ) - except Exception as e: - rel_path = os.path.relpath(root, menagerie) - # Known type/message pairs that we expect to encounter - known_exceptions = [ - # No more known exceptions (yeah!)... but the format is: - # (KeyError, r".*'file'.*", "Need to parse defaults"), - ] - known_failure = False - for exc_type, msg_pattern, note in known_exceptions: - if isinstance(e, exc_type) and re.match( - msg_pattern, str(e) - ): - results += f"FAIL: {os.path.join(rel_path, file)}: {note}\n" - known_failure = True - break - if not known_failure: - results += f"FAIL: {os.path.join(rel_path, file)}: Unregistered exception\n" - raise # Re-raise if not a known exception - print(results) + root = etree.parse(output) + self.assertFalse(root.findall(".//include")) + meshdir = "" + for compiler in root.findall(".//compiler"): + meshdir = compiler.get("meshdir", compiler.get("assetdir", meshdir)) + meshes = root.findall(".//asset/mesh") + self.assertTrue(meshes) + textures = [] + for mesh in meshes: + mesh_path = model_dir / meshdir / mesh.get("file") + self.assertTrue(mesh_path.is_file()) + if mesh_path.suffix.lower() == ".obj": + for line in mesh_path.read_text().splitlines(): + if line.startswith("mtllib "): + material = ( + mesh_path.parent + / line.removeprefix("mtllib ").strip() + ) + self.assertTrue(material.is_file()) + for entry in material.read_text().splitlines(): + if entry.startswith("map_Kd "): + texture = ( + material.parent + / entry.removeprefix("map_Kd ").strip() + ) + self.assertTrue(texture.is_file()) + textures.append(texture) + if relative_scene == "anybotics_anymal_b/scene.xml": + self.assertTrue(textures) + + builder = DiagramBuilder() + plant, _ = AddMultibodyPlantSceneGraph(builder, time_step=0.001) + models = Parser(plant).AddModels(str(output)) + self.assertTrue(models) + plant.Finalize() if __name__ == "__main__": From 210d9813976aa9dbed5d7385687c14e57415765c Mon Sep 17 00:00:00 2001 From: Russ Tedrake Date: Mon, 7 Sep 2026 21:40:29 -0400 Subject: [PATCH 5/9] Use CPU-only PyTorch wheels in pip CI --- .github/workflows/main.yml | 9 ++++++++- docs/testing.md | 6 ++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 38af4c36..48128bfb 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -250,7 +250,14 @@ jobs: # Install system packages as root ./setup/ubuntu/24.04/install_prereqs.sh rm -rf ./manipulation # remove local module, force pip module. - sudo -H -u runner python3 -m pip install --user --break-system-packages --upgrade manipulation[all] pytest pytest-timeout + # CPU wheels are a CI-only choice; leave published requirements unchanged. + sudo -H -u runner python3 -m pip install --user --break-system-packages \ + --index-url https://download.pytorch.org/whl/cpu 'torch>=2.3,<3.0' 'torchvision>=0.18,<1.0' + # Keep the subsequent resolver from replacing these with GPU builds. + sudo -H -u runner python3 -c 'from importlib.metadata import version; print("\n".join(f"{p}=={version(p)}" for p in ("torch", "torchvision")))' > /tmp/ci-torch-constraints.txt + sudo -H -u runner python3 -m pip install --user --break-system-packages \ + --constraint /tmp/ci-torch-constraints.txt --upgrade manipulation[all] pytest pytest-timeout + sudo -H -u runner python3 -c 'import torch, torchvision; print(torch.__version__, torchvision.__version__); assert "+cpu" in torch.__version__ and "+cpu" in torchvision.__version__; assert torch.version.cuda is None' rm -rf /var/cache/apt/archives/*.deb /var/cache/apt/archives/partial/*.deb /var/cache/apt/*.bin /var/lib/apt/lists/* /var/log/apt/* shell: bash diff --git a/docs/testing.md b/docs/testing.md index 3d9e6bcd..d12079c1 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -40,6 +40,12 @@ Linux restoration cost more than the installation time it saved, and macOS did not show a consistent setup improvement. The workflow therefore keeps fresh dependency installation and model prefetch without download cache actions. +The Linux pip job installs CPU-only PyTorch and torchvision wheels from PyTorch's +CPU index. A temporary constraints file preserves those builds during the +`manipulation[all]` install, and the job verifies their build tags afterward. +This choice is confined to CI: the published dependency requirements and Poetry +lockfile are unchanged, and downstream users can continue using GPU builds. + # Menagerie conversion coverage The default conversion test uses Panda (STL meshes, includes, and defaults) and From ebb05c547aa1dc849be09fef288b2df2f8129aa6 Mon Sep 17 00:00:00 2001 From: Russ Tedrake Date: Mon, 7 Sep 2026 21:57:17 -0400 Subject: [PATCH 6/9] Try locked CPU PyTorch builds in Linux Poetry CI --- .github/workflows/main.yml | 4 ++- docs/testing.md | 6 +++++ setup/ci_poetry_cpu.py | 53 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 setup/ci_poetry_cpu.py diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 48128bfb..6072df31 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -151,7 +151,9 @@ jobs: ./setup/ubuntu/24.04/install_prereqs.sh sudo -H -u runner python3 -m pip install --user --break-system-packages poetry==2.1.4 sudo -H -u runner python3 -m poetry config virtualenvs.in-project true - sudo -H -u runner python3 -m poetry install --all-extras --with dev + sudo -H -u runner python3 -m setup.ci_poetry_cpu + sudo -H -u runner python3 -m poetry install --only-root + sudo -H -u runner "$(pwd)/.venv/bin/python" -c 'import torch, torchvision; print(torch.__version__, torchvision.__version__); assert "+cpu" in torch.__version__ and "+cpu" in torchvision.__version__; assert torch.version.cuda is None' rm -rf /var/cache/apt/archives/*.deb /var/cache/apt/archives/partial/*.deb /var/cache/apt/*.bin /var/lib/apt/lists/* /var/log/apt/* shell: bash diff --git a/docs/testing.md b/docs/testing.md index d12079c1..69a5d99f 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -46,6 +46,12 @@ CPU index. A temporary constraints file preserves those builds during the This choice is confined to CI: the published dependency requirements and Poetry lockfile are unchanged, and downstream users can continue using GPU builds. +Linux Poetry CI uses `setup/ci_poetry_cpu.py` to select CPU variants of its locked +torch/torchvision versions. It temporarily adds an explicit source, re-locks, +checks retained dependency versions, and installs dependencies. Both input files +are restored even on failure, before installing the root package with its original +metadata. macOS continues using the committed lockfile directly. + # Menagerie conversion coverage The default conversion test uses Panda (STL meshes, includes, and defaults) and diff --git a/setup/ci_poetry_cpu.py b/setup/ci_poetry_cpu.py new file mode 100644 index 00000000..8bae0d6f --- /dev/null +++ b/setup/ci_poetry_cpu.py @@ -0,0 +1,53 @@ +"""Install Poetry's locked dependencies with CPU PyTorch builds in Linux CI. + +Restore the original manifest and lockfile even when resolution/install fails. +The caller installs the root package afterward using its original metadata. +""" + +import subprocess +import sys +from pathlib import Path + +import tomlkit + + +def main(): + paths = [Path("pyproject.toml"), Path("poetry.lock")] + originals = {path: path.read_bytes() for path in paths} + project = tomlkit.parse(originals[paths[0]].decode()) + locked = tomlkit.parse(originals[paths[1]].decode()) + versions = {p["name"]: p["version"] for p in locked["package"]} + cpu_packages = {"torch", "torchvision"} + source = tomlkit.table() + source.update( + name="pytorch-cpu", + url="https://download.pytorch.org/whl/cpu", + priority="explicit", + ) + project["tool"]["poetry"]["source"].append(source) + for name in cpu_packages: + dependency = project["tool"]["poetry"]["dependencies"][name] + dependency["version"] = versions[name].split("+")[0] + "+cpu" + dependency["source"] = "pytorch-cpu" + + poetry = [sys.executable, "-m", "poetry"] + try: + paths[0].write_text(tomlkit.dumps(project)) + subprocess.run([*poetry, "lock"], check=True) + resolved = tomlkit.parse(paths[1].read_text()) + for package in resolved["package"]: + name = package["name"] + if name not in cpu_packages and name in versions: + if package["version"] != versions[name]: + raise RuntimeError(f"CPU override changed locked version of {name}") + subprocess.run( + [*poetry, "install", "--all-extras", "--with", "dev", "--no-root"], + check=True, + ) + finally: + for path, content in originals.items(): + path.write_bytes(content) + + +if __name__ == "__main__": + main() From 4ab7bf71186def9686057cf79fe7eb0622fc2903 Mon Sep 17 00:00:00 2001 From: Russ Tedrake Date: Mon, 7 Sep 2026 22:01:20 -0400 Subject: [PATCH 7/9] Drop Poetry CPU override after measuring negligible setup savings --- .github/workflows/main.yml | 4 +-- docs/testing.md | 6 ----- setup/ci_poetry_cpu.py | 53 -------------------------------------- 3 files changed, 1 insertion(+), 62 deletions(-) delete mode 100644 setup/ci_poetry_cpu.py diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 6072df31..48128bfb 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -151,9 +151,7 @@ jobs: ./setup/ubuntu/24.04/install_prereqs.sh sudo -H -u runner python3 -m pip install --user --break-system-packages poetry==2.1.4 sudo -H -u runner python3 -m poetry config virtualenvs.in-project true - sudo -H -u runner python3 -m setup.ci_poetry_cpu - sudo -H -u runner python3 -m poetry install --only-root - sudo -H -u runner "$(pwd)/.venv/bin/python" -c 'import torch, torchvision; print(torch.__version__, torchvision.__version__); assert "+cpu" in torch.__version__ and "+cpu" in torchvision.__version__; assert torch.version.cuda is None' + sudo -H -u runner python3 -m poetry install --all-extras --with dev rm -rf /var/cache/apt/archives/*.deb /var/cache/apt/archives/partial/*.deb /var/cache/apt/*.bin /var/lib/apt/lists/* /var/log/apt/* shell: bash diff --git a/docs/testing.md b/docs/testing.md index 69a5d99f..d12079c1 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -46,12 +46,6 @@ CPU index. A temporary constraints file preserves those builds during the This choice is confined to CI: the published dependency requirements and Poetry lockfile are unchanged, and downstream users can continue using GPU builds. -Linux Poetry CI uses `setup/ci_poetry_cpu.py` to select CPU variants of its locked -torch/torchvision versions. It temporarily adds an explicit source, re-locks, -checks retained dependency versions, and installs dependencies. Both input files -are restored even on failure, before installing the root package with its original -metadata. macOS continues using the committed lockfile directly. - # Menagerie conversion coverage The default conversion test uses Panda (STL meshes, includes, and defaults) and diff --git a/setup/ci_poetry_cpu.py b/setup/ci_poetry_cpu.py deleted file mode 100644 index 8bae0d6f..00000000 --- a/setup/ci_poetry_cpu.py +++ /dev/null @@ -1,53 +0,0 @@ -"""Install Poetry's locked dependencies with CPU PyTorch builds in Linux CI. - -Restore the original manifest and lockfile even when resolution/install fails. -The caller installs the root package afterward using its original metadata. -""" - -import subprocess -import sys -from pathlib import Path - -import tomlkit - - -def main(): - paths = [Path("pyproject.toml"), Path("poetry.lock")] - originals = {path: path.read_bytes() for path in paths} - project = tomlkit.parse(originals[paths[0]].decode()) - locked = tomlkit.parse(originals[paths[1]].decode()) - versions = {p["name"]: p["version"] for p in locked["package"]} - cpu_packages = {"torch", "torchvision"} - source = tomlkit.table() - source.update( - name="pytorch-cpu", - url="https://download.pytorch.org/whl/cpu", - priority="explicit", - ) - project["tool"]["poetry"]["source"].append(source) - for name in cpu_packages: - dependency = project["tool"]["poetry"]["dependencies"][name] - dependency["version"] = versions[name].split("+")[0] + "+cpu" - dependency["source"] = "pytorch-cpu" - - poetry = [sys.executable, "-m", "poetry"] - try: - paths[0].write_text(tomlkit.dumps(project)) - subprocess.run([*poetry, "lock"], check=True) - resolved = tomlkit.parse(paths[1].read_text()) - for package in resolved["package"]: - name = package["name"] - if name not in cpu_packages and name in versions: - if package["version"] != versions[name]: - raise RuntimeError(f"CPU override changed locked version of {name}") - subprocess.run( - [*poetry, "install", "--all-extras", "--with", "dev", "--no-root"], - check=True, - ) - finally: - for path, content in originals.items(): - path.write_bytes(content) - - -if __name__ == "__main__": - main() From f88f5be48abdd220d425ca7b586539ebb76bc9ee Mon Sep 17 00:00:00 2001 From: Russ Tedrake Date: Mon, 7 Sep 2026 22:47:22 -0400 Subject: [PATCH 8/9] Trim redundant Ubuntu CI prerequisite installation --- .github/workflows/main.yml | 22 ++++++---------------- docs/testing.md | 6 ++++++ setup/ubuntu/24.04/install_prereqs.sh | 10 ++++++---- 3 files changed, 18 insertions(+), 20 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 48128bfb..ca817775 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -100,17 +100,12 @@ jobs: steps: - name: pre-checkout setup run: | - export APT_KEY_DONT_WARN_ON_DANGEROUS_USAGE=1 DEBIAN_FRONTEND=noninteractive + export DEBIAN_FRONTEND=noninteractive apt-get update -o APT::Acquire::Retries=4 -qq - apt-get install -o APT::Acquire::Retries=4 -o Dpkg::Use-Pty=0 -qy --no-install-recommends ca-certificates gnupg sudo - apt-key adv --keyserver keyserver.ubuntu.com --recv-keys E1DD270288B4E6030699E45FA1715D88E1DF1F24 - echo 'deb http://ppa.launchpad.net/git-core/ppa/ubuntu noble main' > /etc/apt/sources.list.d/git.list - apt-get update -o APT::Acquire::Retries=4 -qq - apt-get install -o APT::Acquire::Retries=4 -o Dpkg::Use-Pty=0 -qy --no-install-recommends git + apt-get install -o APT::Acquire::Retries=4 -o Dpkg::Use-Pty=0 -qy --no-install-recommends ca-certificates sudo git # Create non-root user for running repository setup / tests useradd -m -s /bin/bash runner echo 'runner ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers - rm -rf /var/cache/apt/archives/*.deb /var/cache/apt/archives/partial/*.deb /var/cache/apt/*.bin /var/lib/apt/lists/* /var/log/apt/* shell: bash - name: checkout PR code @@ -148,7 +143,7 @@ jobs: # Switch to runner user for remaining commands sudo -H -u runner ./setup/submodule_checkout # Install system packages as root - ./setup/ubuntu/24.04/install_prereqs.sh + INSTALL_JUPYTER=0 ./setup/ubuntu/24.04/install_prereqs.sh sudo -H -u runner python3 -m pip install --user --break-system-packages poetry==2.1.4 sudo -H -u runner python3 -m poetry config virtualenvs.in-project true sudo -H -u runner python3 -m poetry install --all-extras --with dev @@ -200,17 +195,12 @@ jobs: steps: - name: pre-checkout setup run: | - export APT_KEY_DONT_WARN_ON_DANGEROUS_USAGE=1 DEBIAN_FRONTEND=noninteractive + export DEBIAN_FRONTEND=noninteractive apt-get update -o APT::Acquire::Retries=4 -qq - apt-get install -o APT::Acquire::Retries=4 -o Dpkg::Use-Pty=0 -qy --no-install-recommends ca-certificates gnupg sudo - apt-key adv --keyserver keyserver.ubuntu.com --recv-keys E1DD270288B4E6030699E45FA1715D88E1DF1F24 - echo 'deb http://ppa.launchpad.net/git-core/ppa/ubuntu noble main' > /etc/apt/sources.list.d/git.list - apt-get update -o APT::Acquire::Retries=4 -qq - apt-get install -o APT::Acquire::Retries=4 -o Dpkg::Use-Pty=0 -qy --no-install-recommends git + apt-get install -o APT::Acquire::Retries=4 -o Dpkg::Use-Pty=0 -qy --no-install-recommends ca-certificates sudo git # Create non-root user for running repository setup / tests useradd -m -s /bin/bash runner echo 'runner ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers - rm -rf /var/cache/apt/archives/*.deb /var/cache/apt/archives/partial/*.deb /var/cache/apt/*.bin /var/lib/apt/lists/* /var/log/apt/* shell: bash - name: checkout PR code @@ -248,7 +238,7 @@ jobs: # Switch to runner user for remaining commands sudo -H -u runner ./setup/submodule_checkout # Install system packages as root - ./setup/ubuntu/24.04/install_prereqs.sh + INSTALL_JUPYTER=0 ./setup/ubuntu/24.04/install_prereqs.sh rm -rf ./manipulation # remove local module, force pip module. # CPU wheels are a CI-only choice; leave published requirements unchanged. sudo -H -u runner python3 -m pip install --user --break-system-packages \ diff --git a/docs/testing.md b/docs/testing.md index d12079c1..6ceee1fa 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -65,3 +65,9 @@ TEST_ALL_MENAGERIE=1 .venv/bin/python -m pytest \ This checks conversion for all matching scenes. Asset references and Drake loading are asserted for the two representatives; other upstream scenes can contain dangling material references or use unsupported MJCF features. Generated files stay in temporary directories, outside the model cache. + +Ubuntu CI sets `INSTALL_JUPYTER=0` when installing system prerequisites because +its Python environment supplies the notebook dependencies. The prerequisite +script still installs system Jupyter by default for local users. CI uses +Ubuntu's Git package and retains apt lists through setup to avoid redundant +repository setup and downloads. diff --git a/setup/ubuntu/24.04/install_prereqs.sh b/setup/ubuntu/24.04/install_prereqs.sh index 82cd11c2..69f94bc5 100755 --- a/setup/ubuntu/24.04/install_prereqs.sh +++ b/setup/ubuntu/24.04/install_prereqs.sh @@ -53,17 +53,20 @@ fi apt-get install -o APT::Acquire::Retries=4 -o Dpkg::Use-Pty=0 -qy \ --no-install-recommends ca-certificates gnupg -apt-get update -qq || (sleep 15; apt-get update -qq) +# CI installs notebook dependencies in its Python environment instead. +jupyter_packages=() +if [[ "${INSTALL_JUPYTER:-1}" != "0" ]]; then + jupyter_packages=(jupyter-notebook jupyter-nbconvert) +fi # Keep this up to date with Drake's # setup/ubuntu/binary_distribution/packages-noble.txt, except that we choose to # not install most of the system `python3-*` packages. The second batch are new # requirements from this repo. apt-get install -o APT::Acquire::Retries=4 -o Dpkg::Use-Pty=0 -qy \ - --no-install-recommends $(cat < Date: Mon, 7 Sep 2026 22:53:57 -0400 Subject: [PATCH 9/9] Keep system notebook dependencies for pip CI --- .github/workflows/main.yml | 2 +- docs/testing.md | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index ca817775..4b2cf081 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -238,7 +238,7 @@ jobs: # Switch to runner user for remaining commands sudo -H -u runner ./setup/submodule_checkout # Install system packages as root - INSTALL_JUPYTER=0 ./setup/ubuntu/24.04/install_prereqs.sh + ./setup/ubuntu/24.04/install_prereqs.sh rm -rf ./manipulation # remove local module, force pip module. # CPU wheels are a CI-only choice; leave published requirements unchanged. sudo -H -u runner python3 -m pip install --user --break-system-packages \ diff --git a/docs/testing.md b/docs/testing.md index 6ceee1fa..2848add3 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -66,8 +66,9 @@ This checks conversion for all matching scenes. Asset references and Drake loadi are asserted for the two representatives; other upstream scenes can contain dangling material references or use unsupported MJCF features. Generated files stay in temporary directories, outside the model cache. -Ubuntu CI sets `INSTALL_JUPYTER=0` when installing system prerequisites because +Ubuntu Poetry CI sets `INSTALL_JUPYTER=0` when installing system prerequisites because its Python environment supplies the notebook dependencies. The prerequisite -script still installs system Jupyter by default for local users. CI uses +script still installs system Jupyter by default for local users and pip CI, +whose test harness relies on those packages. CI uses Ubuntu's Git package and retains apt lists through setup to avoid redundant repository setup and downloads.