name: CI on: push: pull_request: workflow_dispatch: jobs: tests: name: Lint and tests runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: "3.11" cache: pip cache-dependency-path: requirements-dev.txt - name: Install system libraries (CPU OpenGL for the video test) run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libosmesa6 libgl1 - run: pip install -r requirements-dev.txt - run: ruff check . && ruff format --check . - run: python -m pytest -q worker-image: name: Build and smoke-test the worker image runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 - name: Build run: docker build --platform linux/amd64 -f worker/Dockerfile -t rosdiff-worker:ci . - name: Run a real job inside the image (CPU rendering; the cloud GPU uses EGL) run: | mkdir -p ci-out && chmod 777 ci-out docker run --rm -i -e MUJOCO_GL=osmesa -e PYOPENGL_PLATFORM=osmesa -e ROSDIFF_LOCAL_STORAGE=/out \ -v "$PWD/ci-out:/out" rosdiff-worker:ci python3 - <<'EOF' import json from worker.handler import handler scene = """ """ out = handler({"input": {"run_id": "ci1", "mjcf": scene, "robot": "unitree_g1", "duration_s": 2}}) print(json.dumps(out, indent=2)[:3000]) assert "error" not in out, out s = out["summary"] assert s["video"] and not s["render_error"], s assert s["simulated_seconds"] >= 1.99 and s["fell"] is False, s assert set(out["files"]) == {"mcap", "video", "video_webm", "summary", "scene", "trajectory"}, out["files"] EOF ls -la ci-out/runs/ci1 - name: Check the handler rejects unsafe scenes run: | docker run --rm -e ROSDIFF_LOCAL_STORAGE=/tmp/o rosdiff-worker:ci python3 -c " from worker.handler import handler out = handler({'input': {'run_id': 'bad', 'mjcf': '', 'robot': 'unitree_g1'}}) assert 'scene rejected' in out['error'], out print('rejected:', out['error'])" - name: Robustness sweep inside the image (MuJoCo CPU and MuJoCo Warp on the CPU device) run: | docker run --rm -i -e MUJOCO_GL=osmesa rosdiff-worker:ci python3 - <<'EOF' from worker.handler import handler scene = """ """ job = {"mjcf": scene, "robot": "unitree_go2", "n_worlds": 3, "duration_s": 1.0, "push_n": [0, 40]} from worker.sweep import run_sweep_job heights = {} for engine in ("mujoco", "warp"): out = run_sweep_job(job, engine=engine) s = out["summary"] print(engine, s["headline"], s.get("engine_note")) assert s["worlds"] == 3 and s["fell"] == 0, s heights[engine] = [w["min_height"] for w in __import__("json").loads(out["artifacts"]["sweep.json"])["worlds"]] print(heights) assert all(abs(a - b) < 0.01 for a, b in zip(heights["mujoco"], heights["warp"])), heights out = handler({"input": {**job, "function": "sweep"}}) assert "error" not in out, out EOF - uses: actions/upload-artifact@v4 if: always() with: name: worker-smoke-output path: ci-out/runs/ci1 if-no-files-found: warn ros-sandbox: name: ROS 2 sandbox (${{ matrix.distro }}) runs-on: ubuntu-24.04 strategy: fail-fast: false matrix: distro: [humble, jazzy] steps: - uses: actions/checkout@v4 - name: Build run: docker build -f worker/ros/Dockerfile --build-arg ROS_DISTRO=${{ matrix.distro }} -t rosdiff-ros:ci . - name: Check the sample packages (build, lint, test, launch, bag) run: | mkdir -p ros-out && chmod 777 ros-out docker run --rm -i -v "$PWD/ros-out:/out" rosdiff-ros:ci python3 - <<'EOF' import json from pathlib import Path from worker.ros_runner import check def run(name): root = Path("/app/samples") / name files = {p.relative_to(root).as_posix(): p.read_text() for p in root.rglob("*") if p.is_file()} r = check(files, "${{ matrix.distro }}", Path("/out") / name) print(f"===== {name}: {r['headline']}") print(json.dumps(r, indent=2)[:4000]) return r r = run("good_py") assert r["passed"] and r["build"]["ok"] and r["launch"]["ok"], r assert "/heartbeat" in r["launch"]["nodes"], r["launch"] assert any(t.startswith("/heartbeat ") for t in r["launch"]["topics"]), r["launch"] assert r.get("bag_bytes", 0) > 0 and Path("/out/good_py/bag.mcap").is_file(), r r = run("good_cpp") assert r["passed"] and "/twist_source" in r["launch"]["nodes"], r assert r["test"]["ran"] and r["test"]["tests"] > 0, r["test"] r = run("broken_cpp") assert not r["passed"] and not r["build"]["ok"], r assert any("speed" in e for e in r["build"]["errors"]), r["build"] r = run("crash_py") assert r["build"]["ok"] and not r["launch"]["ok"] and r["launch"]["crashed"], r["launch"] print("all sample checks behaved as expected") EOF - uses: actions/upload-artifact@v4 if: always() with: name: ros-sandbox-${{ matrix.distro }} path: ros-out if-no-files-found: warn lerobot-export: name: LeRobot dataset export runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: "3.11" - name: Free disk space (unused preinstalled toolchains) run: sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL && df -h / - name: Install (CPU-only PyTorch, LeRobot, the simulator) run: | sudo apt-get update && sudo apt-get install -y --no-install-recommends libosmesa6 libgl1 ffmpeg # One resolve with the CPU wheel index, so LeRobot's torch requirement is met without CUDA (~6 GB). pip install --extra-index-url https://download.pytorch.org/whl/cpu \ lerobot==0.4.4 mujoco==3.14.0 foxglove-sdk==0.27.0 imageio-ffmpeg==0.6.0 boto3 python -c "import torch; assert not torch.version.cuda, torch.__version__; print('torch', torch.__version__)" - name: Simulate two runs, export them, load the dataset back env: MUJOCO_GL: osmesa run: | git clone -q --depth 1 --filter=blob:none --sparse https://github.com/google-deepmind/mujoco_menagerie.git /tmp/men git -C /tmp/men sparse-checkout set unitree_go2 python - <<'EOF' from pathlib import Path from api.mjcf_check import prepare_scene_dir from worker.lerobot_export import run_export_job from worker.sim_runner import config_for, simulate scene = """ """ episodes = [] for i, controller in enumerate(("hold", "passive")): d = prepare_scene_dir(scene, "unitree_go2", Path("/tmp/men")) cfg = config_for("unitree_go2", 1.0, controller) cfg.width, cfg.height = 320, 180 simulate(d / "scene.xml", Path(f"/tmp/run{i}"), cfg) episodes.append({"run_id": f"r{i}", "task": f"Go2 {controller}", "trajectory_url": f"/tmp/run{i}/trajectory.npz", "video_url": f"/tmp/run{i}/video.mp4"}) out = run_export_job({"repo_id": "ci/rosdiff-go2", "robot": "unitree_go2", "episodes": episodes}) print(out["summary"]) assert out["summary"]["episodes"] == 2, out["summary"] Path("/tmp/ds.zip").write_bytes(out["artifacts"]["dataset.zip"]) import shutil shutil.unpack_archive("/tmp/ds.zip", "/tmp/ds") from lerobot.datasets.lerobot_dataset import LeRobotDataset ds = LeRobotDataset("ci/rosdiff-go2", root="/tmp/ds") item = ds[len(ds) - 1] print({k: tuple(v.shape) if hasattr(v, "shape") else v for k, v in item.items()}) assert ds.num_episodes == 2 and item["task"] == "Go2 passive" assert tuple(item["observation.images.main"].shape) == (3, 360, 640) assert tuple(item["observation.state"].shape) == (19,) and tuple(item["action"].shape) == (12,) EOF