diff --git a/.github/actions/install-ci-run/action.yml b/.github/actions/install-ci-run/action.yml
index e099be41e561..c7b13c23fbfd 100644
--- a/.github/actions/install-ci-run/action.yml
+++ b/.github/actions/install-ci-run/action.yml
@@ -54,3 +54,10 @@ runs:
path: ${{ github.workspace }}/results/results.xml
if-no-files-found: ignore
retention-days: 7
+
+ - name: Upload omni-github test results
+ if: always()
+ uses: ./.github/actions/upload-omni-github-test-results
+ with:
+ junit-file: ${{ github.workspace }}/results/results.xml
+ artifact-prefix: pytest-results-${{ github.job }}-${{ runner.arch }}
diff --git a/.github/actions/run-package-tests/action.yml b/.github/actions/run-package-tests/action.yml
index 264e01a158b8..4f4df2487166 100644
--- a/.github/actions/run-package-tests/action.yml
+++ b/.github/actions/run-package-tests/action.yml
@@ -150,6 +150,7 @@ runs:
include-files: ${{ inputs.include-files }}
volume-mount-source: ${{ github.workspace }}
extra-pip-packages: ${{ inputs.extra-pip-packages }}
+ omni-github-artifact-prefix: pytest-results-${{ github.job }}-${{ inputs.container-name }}
- name: Check Test Results
if: always()
diff --git a/.github/actions/run-tests/action.yml b/.github/actions/run-tests/action.yml
index ceea3a1c4e65..925045f3f251 100644
--- a/.github/actions/run-tests/action.yml
+++ b/.github/actions/run-tests/action.yml
@@ -69,6 +69,10 @@ inputs:
description: 'Space-separated pip packages to install inside the Docker container before pytest starts'
default: ''
required: false
+ omni-github-artifact-prefix:
+ description: 'Optional human-readable prefix for the omni-github test-result artifact'
+ default: ''
+ required: false
runs:
using: composite
@@ -437,6 +441,13 @@ runs:
if-no-files-found: ignore
retention-days: 7
+ - name: Upload omni-github test results
+ if: always()
+ uses: ./.github/actions/upload-omni-github-test-results
+ with:
+ junit-file: ${{ inputs.reports-dir }}/${{ inputs.result-file }}
+ artifact-prefix: ${{ inputs.omni-github-artifact-prefix || format('pytest-results-{0}-{1}', github.job, inputs.container-name) }}
+
- name: Clean up Docker container
if: always() && !cancelled()
shell: bash
diff --git a/.github/actions/upload-omni-github-test-results/action.yml b/.github/actions/upload-omni-github-test-results/action.yml
new file mode 100644
index 000000000000..d00071829483
--- /dev/null
+++ b/.github/actions/upload-omni-github-test-results/action.yml
@@ -0,0 +1,120 @@
+# 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: 'Upload omni-github test results'
+description: >
+ Converts a JUnit XML report into the omni-github test-result artifact
+ contract and uploads it with the current GitHub repository identity.
+
+inputs:
+ junit-file:
+ description: 'Path to the JUnit XML report to convert'
+ required: true
+ artifact-prefix:
+ description: 'Human-readable artifact prefix; normalized before upload'
+ required: true
+ test-tool-id:
+ description: 'Identifier for the test runner that produced the JUnit report'
+ default: 'pytest'
+ required: false
+ test-type:
+ description: 'Test type stored on each converted test row'
+ default: 'pytest'
+ required: false
+ app-platform:
+ description: 'omni-github app.platform value; defaults from runner OS and architecture'
+ default: ''
+ required: false
+ app-config:
+ description: 'omni-github app.config value; defaults to the GitHub job id'
+ default: ''
+ required: false
+ retention-days:
+ description: 'GitHub artifact retention in days'
+ default: '7'
+ required: false
+
+runs:
+ using: composite
+ steps:
+ - name: Convert JUnit XML to omni-github results
+ id: convert
+ shell: bash
+ env:
+ APP_CONFIG: ${{ inputs.app-config }}
+ APP_PLATFORM: ${{ inputs.app-platform }}
+ ARTIFACT_PREFIX: ${{ inputs.artifact-prefix }}
+ JUNIT_FILE: ${{ inputs.junit-file }}
+ TEST_TOOL_ID: ${{ inputs.test-tool-id }}
+ TEST_TYPE: ${{ inputs.test-type }}
+ run: |
+ set -euo pipefail
+
+ if [ ! -f "$JUNIT_FILE" ]; then
+ echo "::warning::Skipping omni-github upload because JUnit report was not found: $JUNIT_FILE"
+ echo "upload=false" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+
+ artifact_prefix="$(
+ printf '%s' "$ARTIFACT_PREFIX" \
+ | tr '[:upper:]' '[:lower:]' \
+ | sed -E 's/[^a-z0-9_]+/-/g; s/-+/-/g; s/^-+//; s/-+$//'
+ )"
+ if [ -z "$artifact_prefix" ]; then
+ artifact_prefix="pytest-results"
+ fi
+
+ app_platform="$APP_PLATFORM"
+ if [ -z "$app_platform" ]; then
+ case "${RUNNER_OS:-unknown}-${RUNNER_ARCH:-unknown}" in
+ Linux-X64) app_platform="linux-x86_64" ;;
+ Linux-ARM64) app_platform="linux-aarch64" ;;
+ Windows-X64) app_platform="windows-x86_64" ;;
+ Windows-ARM64) app_platform="windows-aarch64" ;;
+ macOS-X64) app_platform="macos-x86_64" ;;
+ macOS-ARM64) app_platform="macos-aarch64" ;;
+ *) app_platform="$(printf '%s-%s' "${RUNNER_OS:-unknown}" "${RUNNER_ARCH:-unknown}" | tr '[:upper:]' '[:lower:]')" ;;
+ esac
+ fi
+
+ app_config="$APP_CONFIG"
+ if [ -z "$app_config" ]; then
+ app_config="${GITHUB_JOB:-github-job}"
+ fi
+
+ artifact_dir="${RUNNER_TEMP}/omni-github-test-results/${artifact_prefix}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-${GITHUB_JOB}"
+ rm -rf "$artifact_dir"
+ mkdir -p "$artifact_dir"
+
+ python3 "$GITHUB_ACTION_PATH/junit_to_omni_github_results.py" \
+ --junit-file "$JUNIT_FILE" \
+ --output-dir "$artifact_dir" \
+ --test-tool-id "$TEST_TOOL_ID" \
+ --test-type "$TEST_TYPE" \
+ --app-platform "$app_platform" \
+ --app-config "$app_config"
+
+ manifest_path="${artifact_dir}/omni-github-test-results-upload.json"
+ result_path="${artifact_dir}/_testoutput/test_results.json"
+ if [ ! -f "$manifest_path" ] || [ ! -f "$result_path" ]; then
+ echo "::notice::Skipping omni-github upload because no schema-valid result artifact was generated for JUnit report: $JUNIT_FILE"
+ echo "upload=false" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+
+ echo "artifact_dir=$artifact_dir" >> "$GITHUB_OUTPUT"
+ echo "artifact_prefix=$artifact_prefix" >> "$GITHUB_OUTPUT"
+ echo "upload=true" >> "$GITHUB_OUTPUT"
+
+ - name: Upload omni-github test results
+ if: always() && steps.convert.outputs.upload == 'true'
+ uses: actions/upload-artifact@v7
+ with:
+ name: ${{ steps.convert.outputs.artifact_prefix }}--v1-${{ github.repository_id }}-${{ github.run_id }}-${{ github.run_attempt }}-${{ job.check_run_id }}
+ path: ${{ steps.convert.outputs.artifact_dir }}
+ if-no-files-found: error
+ retention-days: ${{ inputs.retention-days }}
+ compression-level: 9
diff --git a/.github/actions/upload-omni-github-test-results/junit_to_omni_github_results.py b/.github/actions/upload-omni-github-test-results/junit_to_omni_github_results.py
new file mode 100644
index 000000000000..da0cf601f0df
--- /dev/null
+++ b/.github/actions/upload-omni-github-test-results/junit_to_omni_github_results.py
@@ -0,0 +1,160 @@
+# 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
+
+"""Convert a JUnit XML report into the omni-github test-result artifact format."""
+
+from __future__ import annotations
+
+import argparse
+import json
+import xml.etree.ElementTree as ET
+from pathlib import Path
+
+_MANIFEST_NAME = "omni-github-test-results-upload.json"
+_RESULT_PATH = "_testoutput/test_results.json"
+
+
+def _local_name(tag: str) -> str:
+ """Return an XML tag name without its namespace."""
+ return tag.rsplit("}", maxsplit=1)[-1]
+
+
+def _iter_testcases(root: ET.Element) -> list[ET.Element]:
+ """Return all JUnit testcase elements from the parsed XML tree."""
+ return [element for element in root.iter() if _local_name(element.tag) == "testcase"]
+
+
+def _has_child(testcase: ET.Element, names: set[str]) -> bool:
+ """Return whether a testcase has a direct child with one of the given names."""
+ return any(_local_name(child.tag) in names for child in testcase)
+
+
+def _find_child(testcase: ET.Element, names: set[str]) -> ET.Element | None:
+ """Return the first direct testcase child with one of the given names."""
+ for child in testcase:
+ if _local_name(child.tag) in names:
+ return child
+ return None
+
+
+def _duration_seconds(testcase: ET.Element) -> float:
+ """Return the testcase duration in seconds."""
+ try:
+ duration = float(testcase.attrib.get("time", "0"))
+ except ValueError:
+ duration = 0.0
+ return max(duration, 0.0)
+
+
+def _test_id(testcase: ET.Element) -> str:
+ """Return a stable test identifier from JUnit classname and name fields."""
+ name = testcase.attrib.get("name", "").strip()
+ classname = testcase.attrib.get("classname", "").strip()
+ if classname and name:
+ return f"{classname}::{name}"
+ return name or classname or "unknown-testcase"
+
+
+def _message(element: ET.Element | None) -> str | None:
+ """Return a bounded JUnit child message, if present."""
+ if element is None:
+ return None
+ message = element.attrib.get("message", "").strip()
+ return message[:4096] if message else None
+
+
+def _convert_testcase(testcase: ET.Element, test_type: str) -> dict[str, object]:
+ """Convert one JUnit testcase element into an omni-github test row."""
+ skipped = _find_child(testcase, {"skipped"})
+ failed_or_error = _has_child(testcase, {"error", "failure"})
+ row: dict[str, object] = {
+ "test_id": _test_id(testcase),
+ "passed": not failed_or_error and skipped is None,
+ "duration": _duration_seconds(testcase),
+ "test_type": test_type,
+ }
+ if skipped is not None:
+ row["skipped"] = True
+ if skip_reason := _message(skipped):
+ row["skip_reason"] = skip_reason
+ return row
+
+
+def convert_junit(
+ junit_file: Path,
+ output_dir: Path,
+ test_tool_id: str,
+ test_type: str,
+ app_platform: str,
+ app_config: str,
+) -> int:
+ """Convert a JUnit XML report and write the omni-github artifact directory.
+
+ Args:
+ junit_file: Path to the source JUnit XML report.
+ output_dir: Directory where the artifact root should be written.
+ test_tool_id: Identifier for the test tool that produced the report.
+ test_type: Test type to store on each converted test row.
+ app_platform: Platform label for the result app metadata.
+ app_config: Configuration label for the result app metadata.
+
+ Returns:
+ Number of test cases converted.
+ """
+ root = ET.parse(junit_file).getroot()
+ tests = [_convert_testcase(testcase, test_type) for testcase in _iter_testcases(root)]
+ if not tests:
+ return 0
+
+ result: dict[str, object] = {
+ "result_schema_version": 1,
+ "test_tool_id": test_tool_id,
+ "app": {
+ "platform": app_platform,
+ "config": app_config,
+ },
+ "tests": tests,
+ }
+ manifest = {
+ "schema_version": 1,
+ "result_paths": [_RESULT_PATH],
+ }
+
+ result_path = output_dir / _RESULT_PATH
+ result_path.parent.mkdir(parents=True, exist_ok=True)
+ result_path.write_text(json.dumps(result, indent=2, sort_keys=True) + "\n", encoding="utf-8")
+ (output_dir / _MANIFEST_NAME).write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
+ return len(tests)
+
+
+def parse_args() -> argparse.Namespace:
+ """Parse command-line arguments."""
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--junit-file", type=Path, required=True, help="Path to the JUnit XML report.")
+ parser.add_argument("--output-dir", type=Path, required=True, help="Artifact root directory to write.")
+ parser.add_argument("--test-tool-id", required=True, help="Identifier for the test tool.")
+ parser.add_argument("--test-type", required=True, help="Test type for each test row.")
+ parser.add_argument("--app-platform", required=True, help="App platform label.")
+ parser.add_argument("--app-config", required=True, help="App configuration label.")
+ return parser.parse_args()
+
+
+def main() -> None:
+ """Run the converter."""
+ args = parse_args()
+ test_count = convert_junit(
+ junit_file=args.junit_file,
+ output_dir=args.output_dir,
+ test_tool_id=args.test_tool_id,
+ test_type=args.test_type,
+ app_platform=args.app_platform,
+ app_config=args.app_config,
+ )
+ if test_count == 0:
+ print("No JUnit test cases found; skipping omni-github artifact generation.")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/.github/actions/upload-omni-github-test-results/test_junit_to_omni_github_results.py b/.github/actions/upload-omni-github-test-results/test_junit_to_omni_github_results.py
new file mode 100644
index 000000000000..9b03f57d30df
--- /dev/null
+++ b/.github/actions/upload-omni-github-test-results/test_junit_to_omni_github_results.py
@@ -0,0 +1,118 @@
+# 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
+
+"""Tests for converting JUnit XML into omni-github test-result artifacts."""
+
+from __future__ import annotations
+
+import importlib.util
+import json
+from pathlib import Path
+from types import ModuleType
+
+_CONVERTER_PATH = Path(__file__).with_name("junit_to_omni_github_results.py")
+_MANIFEST_NAME = "omni-github-test-results-upload.json"
+_RESULT_PATH = Path("_testoutput/test_results.json")
+
+
+def _load_converter() -> ModuleType:
+ """Load the converter module from its action-local script path."""
+ spec = importlib.util.spec_from_file_location("junit_to_omni_github_results", _CONVERTER_PATH)
+ assert spec is not None
+ assert spec.loader is not None
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module
+
+
+def test_convert_junit_writes_schema_compatible_artifact(tmp_path: Path) -> None:
+ """Test that JUnit test cases are converted into manifest-listed result rows."""
+ junit_file = tmp_path / "junit.xml"
+ junit_file.write_text(
+ """
+
+
+
+
+
+
+
+
+
+""",
+ encoding="utf-8",
+ )
+ output_dir = tmp_path / "artifact"
+
+ test_count = _load_converter().convert_junit(
+ junit_file=junit_file,
+ output_dir=output_dir,
+ test_tool_id="pytest",
+ test_type="unit",
+ app_platform="linux-x86_64",
+ app_config="test-job",
+ )
+
+ assert test_count == 3
+ manifest = json.loads((output_dir / _MANIFEST_NAME).read_text(encoding="utf-8"))
+ result = json.loads((output_dir / _RESULT_PATH).read_text(encoding="utf-8"))
+ assert manifest == {
+ "schema_version": 1,
+ "result_paths": [_RESULT_PATH.as_posix()],
+ }
+ assert result["result_schema_version"] == 1
+ assert result["test_tool_id"] == "pytest"
+ assert result["app"] == {
+ "platform": "linux-x86_64",
+ "config": "test-job",
+ }
+ assert result["tests"] == [
+ {
+ "duration": 0.125,
+ "passed": True,
+ "test_id": "tests.test_demo::test_passes",
+ "test_type": "unit",
+ },
+ {
+ "duration": 1.25,
+ "passed": False,
+ "test_id": "tests.test_demo::test_fails",
+ "test_type": "unit",
+ },
+ {
+ "duration": 0.0,
+ "passed": False,
+ "skip_reason": "not applicable",
+ "skipped": True,
+ "test_id": "tests.test_demo::test_skips",
+ "test_type": "unit",
+ },
+ ]
+
+
+def test_convert_junit_skips_artifact_when_no_testcases(tmp_path: Path) -> None:
+ """Test that an empty JUnit report does not create a schema-invalid artifact."""
+ junit_file = tmp_path / "junit.xml"
+ junit_file.write_text(
+ """
+
+""",
+ encoding="utf-8",
+ )
+ output_dir = tmp_path / "artifact"
+ output_dir.mkdir()
+
+ test_count = _load_converter().convert_junit(
+ junit_file=junit_file,
+ output_dir=output_dir,
+ test_tool_id="pytest",
+ test_type="unit",
+ app_platform="linux-x86_64",
+ app_config="test-job",
+ )
+
+ assert test_count == 0
+ assert not (output_dir / _MANIFEST_NAME).exists()
+ assert not (output_dir / _RESULT_PATH).exists()