Full Tests workflow (jokeru8/piper_lerobot)
The Full Tests workflow from jokeru8/piper_lerobot, explained and optimized by Latchkey.
CI health: B - good
Point runs-on at Latchkey and get job timeouts, SHA-pinned actions, self-healing for flaky steps, and up to 58% lower cost, applied automatically.
What it does
This is the Full Tests workflow from the jokeru8/piper_lerobot repository, a real project running GitHub Actions. It is shown here with attribution under its Apache-2.0 license.
Below, Latchkey shows a faster, safer version produced by its optimization engine.
The workflow
# Copyright 2025 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# This workflow handles full testing.
name: Full Tests
on:
# Allows running this workflow manually from the Actions tab
workflow_dispatch:
pull_request_review:
types: [submitted]
push:
branches:
- main
paths:
- "src/**"
- "tests/**"
- ".github/workflows/**"
- "pyproject.toml"
- "Makefile"
permissions:
contents: read
# Sets up the environment variables
env:
UV_VERSION: "0.8.0"
PYTHON_VERSION: "3.10"
DOCKER_IMAGE_NAME: huggingface/lerobot-gpu
# Ensures that only the latest action is built, canceling older runs.
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
# This job runs the E2E tests + pytest with all extras
# It runs everytime a PR is approved or a push to main
full-tests:
name: Full Tests
runs-on: ubuntu-latest
if: |
(github.event_name == 'pull_request_review' && github.event.review.state == 'approved') ||
github.event_name == 'push' ||
github.event_name == 'workflow_dispatch'
env:
MUJOCO_GL: egl
steps:
- uses: actions/checkout@v4
with:
lfs: true
persist-credentials: false
- name: Install apt dependencies
run: |
sudo apt-get update && sudo apt-get install -y build-essential \
git curl libglib2.0-0 libegl1-mesa-dev ffmpeg libusb-1.0-0-dev \
speech-dispatcher libgeos-dev portaudio19-dev
- name: Setup uv and Python
uses: astral-sh/setup-uv@v6 # zizmor: ignore[unpinned-uses]
with:
enable-cache: true
version: ${{ env.UV_VERSION }}
python-version: ${{ env.PYTHON_VERSION }}
- name: Install lerobot with all extras
run: uv sync --all-extras --no-extra groot # TODO(Steven): Make flash-attn optional
- name: Run pytest (all extras)
run: uv run pytest tests -vv --maxfail=10
- name: Run end-to-end tests
run: uv run make test-end-to-end
# This job builds a GPU enabled image for testing
# It runs everytime a PR is approved or a push to main
# TODO(Steven): For now we skip this job for community PRs
build-and-push-docker:
name: Build and Push Docker
runs-on:
group: aws-general-8-plus
if: |
(github.event_name == 'pull_request_review' && github.event.review.state == 'approved' && github.event.pull_request.head.repo.fork == false) ||
github.event_name == 'push' ||
github.event_name == 'workflow_dispatch'
outputs:
image_tag: ${{ steps.set_tag.outputs.image_tag }}
env:
GITHUB_EVENT_NAME: ${{ github.event_name }}
GITHUB_REF: ${{ github.ref }}
GITHUB_PR_NUMBER: ${{ github.event.pull_request.number }}
steps:
- name: Set Docker image tag
id: set_tag
run: |
if [[ "${GITHUB_EVENT_NAME}" == "push" ]]; then
TAG="${DOCKER_IMAGE_NAME}:latest"
elif [[ -n "${GITHUB_PR_NUMBER}" ]]; then
TAG="${DOCKER_IMAGE_NAME}:pr-${GITHUB_PR_NUMBER}"
else
TAG="${DOCKER_IMAGE_NAME}:pr-${GITHUB_REF##*/}"
fi
echo "image_tag=$TAG" >> $GITHUB_OUTPUT
- name: Install Git LFS
run: |
sudo apt-get update
sudo apt-get install git-lfs
git lfs install
- uses: actions/checkout@v4
with:
lfs: true
persist-credentials: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 # zizmor: ignore[unpinned-uses]
with:
cache-binary: false
- name: Login to Docker Hub
uses: docker/login-action@v3 # zizmor: ignore[unpinned-uses]
with:
username: ${{ secrets.DOCKERHUB_LEROBOT_USERNAME }}
password: ${{ secrets.DOCKERHUB_LEROBOT_PASSWORD }}
- name: Build and push Docker image
uses: docker/build-push-action@v6 # zizmor: ignore[unpinned-uses]
with:
context: .
file: ./docker/Dockerfile.internal
push: true
tags: ${{ steps.set_tag.outputs.image_tag }}
# This job runs pytest with all extras in a GPU enabled host
# It runs everytime a test image is created
gpu-tests:
name: GPU Tests
needs: [build-and-push-docker]
runs-on:
group: aws-g6-4xlarge-plus
env:
HF_HOME: /home/user_lerobot/.cache/huggingface
HF_LEROBOT_HOME: /home/user_lerobot/.cache/huggingface/lerobot
TORCH_HOME: /home/user_lerobot/.cache/torch
TRITON_CACHE_DIR: /home/user_lerobot/.cache/triton
container:
image: ${{ needs.build-and-push-docker.outputs.image_tag }} # zizmor: ignore[unpinned-images]
options: --gpus all --shm-size "16gb"
credentials:
username: ${{ secrets.DOCKERHUB_LEROBOT_USERNAME }}
password: ${{ secrets.DOCKERHUB_LEROBOT_PASSWORD }}
defaults:
run:
shell: bash
working-directory: /lerobot
steps:
- name: Run pytest on GPU
run: pytest tests -vv --maxfail=10
- name: Run end-to-end tests
run: make test-end-to-end
# This job deletes the test image recently created
# It runs everytime after the gpu-tests have finished
delete-pr-image:
name: Delete PR Image
needs: [gpu-tests, build-and-push-docker]
if: always() && ((github.event.review.state == 'approved') || (github.event_name == 'workflow_dispatch')) && needs.build-and-push-docker.result == 'success'
runs-on: ubuntu-latest
steps:
- name: Get Docker Hub Token and Delete Image
# zizmor: ignore[template-injection]
run: |
IMAGE_NAME=$(echo "${{ needs.build-and-push-docker.outputs.image_tag }}" | cut -d':' -f1)
IMAGE_TAG=$(echo "${{ needs.build-and-push-docker.outputs.image_tag }}" | cut -d':' -f2)
echo "Attempting to delete image: $IMAGE_NAME:$IMAGE_TAG"
TOKEN=$(curl -s -H "Content-Type: application/json" \
-X POST \
-d '{"username": "${{ secrets.DOCKERHUB_LEROBOT_USERNAME }}", "password": "${{ secrets.DOCKERHUB_LEROBOT_PASSWORD }}"}' \
https://hub.docker.com/v2/users/login/ | jq -r .token)
if [ "$TOKEN" == "null" ] || [ -z "$TOKEN" ]; then
echo "::error::Failed to get Docker Hub token."
exit 1
fi
HTTP_RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" \
-H "Authorization: JWT ${TOKEN}" \
-X DELETE \
https://hub.docker.com/v2/repositories/${IMAGE_NAME}/tags/${IMAGE_TAG}/)
if [ "$HTTP_RESPONSE" -eq 204 ]; then
echo "Successfully deleted Docker image tag: $IMAGE_NAME:$IMAGE_TAG"
else
echo "::error::Failed to delete Docker image. HTTP status: $HTTP_RESPONSE"
exit 1
fi
# TODO(Steven): Check dockerimages pull in ubuntu
The same workflow, on Latchkey
Removes redundant runs and caps runaway jobs. Added and changed lines are highlighted.
# Copyright 2025 The HuggingFace Inc. team. All rights reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # This workflow handles full testing. name: Full Tests on: # Allows running this workflow manually from the Actions tab workflow_dispatch: pull_request_review: types: [submitted] push: branches: - main paths: - "src/**" - "tests/**" - ".github/workflows/**" - "pyproject.toml" - "Makefile" permissions: contents: read # Sets up the environment variables env: UV_VERSION: "0.8.0" PYTHON_VERSION: "3.10" DOCKER_IMAGE_NAME: huggingface/lerobot-gpu # Ensures that only the latest action is built, canceling older runs. concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} cancel-in-progress: true jobs: # This job runs the E2E tests + pytest with all extras # It runs everytime a PR is approved or a push to main full-tests: timeout-minutes: 30 name: Full Tests runs-on: latchkey-small if: | (github.event_name == 'pull_request_review' && github.event.review.state == 'approved') || github.event_name == 'push' || github.event_name == 'workflow_dispatch' env: MUJOCO_GL: egl steps: - uses: actions/checkout@v4 with: lfs: true persist-credentials: false - name: Install apt dependencies run: | sudo apt-get update && sudo apt-get install -y build-essential \ git curl libglib2.0-0 libegl1-mesa-dev ffmpeg libusb-1.0-0-dev \ speech-dispatcher libgeos-dev portaudio19-dev - name: Setup uv and Python uses: astral-sh/setup-uv@v6 # zizmor: ignore[unpinned-uses] with: enable-cache: true version: ${{ env.UV_VERSION }} python-version: ${{ env.PYTHON_VERSION }} - name: Install lerobot with all extras run: uv sync --all-extras --no-extra groot # TODO(Steven): Make flash-attn optional - name: Run pytest (all extras) run: uv run pytest tests -vv --maxfail=10 - name: Run end-to-end tests run: uv run make test-end-to-end # This job builds a GPU enabled image for testing # It runs everytime a PR is approved or a push to main # TODO(Steven): For now we skip this job for community PRs build-and-push-docker: timeout-minutes: 30 name: Build and Push Docker runs-on: group: aws-general-8-plus if: | (github.event_name == 'pull_request_review' && github.event.review.state == 'approved' && github.event.pull_request.head.repo.fork == false) || github.event_name == 'push' || github.event_name == 'workflow_dispatch' outputs: image_tag: ${{ steps.set_tag.outputs.image_tag }} env: GITHUB_EVENT_NAME: ${{ github.event_name }} GITHUB_REF: ${{ github.ref }} GITHUB_PR_NUMBER: ${{ github.event.pull_request.number }} steps: - name: Set Docker image tag id: set_tag run: | if [[ "${GITHUB_EVENT_NAME}" == "push" ]]; then TAG="${DOCKER_IMAGE_NAME}:latest" elif [[ -n "${GITHUB_PR_NUMBER}" ]]; then TAG="${DOCKER_IMAGE_NAME}:pr-${GITHUB_PR_NUMBER}" else TAG="${DOCKER_IMAGE_NAME}:pr-${GITHUB_REF##*/}" fi echo "image_tag=$TAG" >> $GITHUB_OUTPUT - name: Install Git LFS run: | sudo apt-get update sudo apt-get install git-lfs git lfs install - uses: actions/checkout@v4 with: lfs: true persist-credentials: false - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 # zizmor: ignore[unpinned-uses] with: cache-binary: false - name: Login to Docker Hub uses: docker/login-action@v3 # zizmor: ignore[unpinned-uses] with: username: ${{ secrets.DOCKERHUB_LEROBOT_USERNAME }} password: ${{ secrets.DOCKERHUB_LEROBOT_PASSWORD }} - name: Build and push Docker image uses: docker/build-push-action@v6 # zizmor: ignore[unpinned-uses] with: context: . file: ./docker/Dockerfile.internal push: true tags: ${{ steps.set_tag.outputs.image_tag }} # This job runs pytest with all extras in a GPU enabled host # It runs everytime a test image is created gpu-tests: timeout-minutes: 30 name: GPU Tests needs: [build-and-push-docker] runs-on: group: aws-g6-4xlarge-plus env: HF_HOME: /home/user_lerobot/.cache/huggingface HF_LEROBOT_HOME: /home/user_lerobot/.cache/huggingface/lerobot TORCH_HOME: /home/user_lerobot/.cache/torch TRITON_CACHE_DIR: /home/user_lerobot/.cache/triton container: image: ${{ needs.build-and-push-docker.outputs.image_tag }} # zizmor: ignore[unpinned-images] options: --gpus all --shm-size "16gb" credentials: username: ${{ secrets.DOCKERHUB_LEROBOT_USERNAME }} password: ${{ secrets.DOCKERHUB_LEROBOT_PASSWORD }} defaults: run: shell: bash working-directory: /lerobot steps: - name: Run pytest on GPU run: pytest tests -vv --maxfail=10 - name: Run end-to-end tests run: make test-end-to-end # This job deletes the test image recently created # It runs everytime after the gpu-tests have finished delete-pr-image: timeout-minutes: 30 name: Delete PR Image needs: [gpu-tests, build-and-push-docker] if: always() && ((github.event.review.state == 'approved') || (github.event_name == 'workflow_dispatch')) && needs.build-and-push-docker.result == 'success' runs-on: latchkey-small steps: - name: Get Docker Hub Token and Delete Image # zizmor: ignore[template-injection] run: | IMAGE_NAME=$(echo "${{ needs.build-and-push-docker.outputs.image_tag }}" | cut -d':' -f1) IMAGE_TAG=$(echo "${{ needs.build-and-push-docker.outputs.image_tag }}" | cut -d':' -f2) echo "Attempting to delete image: $IMAGE_NAME:$IMAGE_TAG" TOKEN=$(curl -s -H "Content-Type: application/json" \ -X POST \ -d '{"username": "${{ secrets.DOCKERHUB_LEROBOT_USERNAME }}", "password": "${{ secrets.DOCKERHUB_LEROBOT_PASSWORD }}"}' \ https://hub.docker.com/v2/users/login/ | jq -r .token) if [ "$TOKEN" == "null" ] || [ -z "$TOKEN" ]; then echo "::error::Failed to get Docker Hub token." exit 1 fi HTTP_RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" \ -H "Authorization: JWT ${TOKEN}" \ -X DELETE \ https://hub.docker.com/v2/repositories/${IMAGE_NAME}/tags/${IMAGE_TAG}/) if [ "$HTTP_RESPONSE" -eq 204 ]; then echo "Successfully deleted Docker image tag: $IMAGE_NAME:$IMAGE_TAG" else echo "::error::Failed to delete Docker image. HTTP status: $HTTP_RESPONSE" exit 1 fi # TODO(Steven): Check dockerimages pull in ubuntu
What changed
- Run on Latchkey managed runners with one line (
runs-on), which apply the fixes below automatically and self-heal transient failures. This example useslatchkey-small; pick the runner size that fits the job. - Add a job timeout so a hung step cannot burn hours of runner time.
4 third-party actions are referenced by a movable tag. Pin them to the commit SHA (Latchkey resolves and applies this automatically) so a repointed tag cannot change what runs.
What Latchkey heals here
This workflow has steps that commonly fail on transient issues (network, registries, flaky browsers). On Latchkey managed runners they are detected, retried, and self-healed instead of failing your build:
- Dependency installs
- Container pulls and builds
- End-to-end and browser tests
- Network fetches
This workflow runs 4 jobs per trigger. On Latchkey the same minutes cost up to 58% less than GitHub-hosted, with zero queue time.