Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 49 additions & 17 deletions .github/actions/build/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ inputs:
conan-args:
required: false
default: ""
save-conan-cache:
description: Save the shared platform cache; enable only for a full dependency producer.
required: false
default: false
is-canary:
description: If a canary build is being performed.
required: false
Expand Down Expand Up @@ -70,30 +74,41 @@ runs:
echo "CMAKE_BUILD_PARALLEL_LEVEL=$(python -c 'import os; print(os.cpu_count() or 1)')" >> $GITHUB_ENV
fi

- name: Cache Conan
uses: actions/cache@v5
- name: Reset sccache statistics
continue-on-error: true
shell: bash
run: |
if command -v sccache >/dev/null 2>&1; then
sccache --zero-stats
fi

- name: Prepare Conan cache context
id: conan-context
shell: bash
env:
RUNNER_ARCH: ${{ runner.arch }}
run: python .github/scripts/configure_conan_profile.py --emit-cache-fingerprint

- name: Restore Conan cache
id: conan-cache
continue-on-error: true
uses: actions/cache/restore@v5
with:
path: ~/.conan2
key: >-
conan-${{ runner.os }}-py${{ inputs.python-version }}-
${{ hashFiles('conan.lock','conanfile.*','**/conanfile.*','CMakeLists.txt','**/*.cmake') }}
key: conan-v4-${{ runner.os }}-${{ runner.arch }}-${{ steps.conan-context.outputs.fingerprint }}-${{ hashFiles('conan.lock', 'conanfile.py', 'libs/**') }}
restore-keys: |
conan-v4-${{ runner.os }}-${{ runner.arch }}-${{ steps.conan-context.outputs.fingerprint }}-
conan-v3-${{ runner.os }}-${{ runner.arch }}-py${{ inputs.python-version }}-${{ steps.conan-context.outputs.fingerprint }}-
conan-v3-${{ runner.os }}-${{ runner.arch }}-py${{ inputs.python-version }}-
conan-v2-${{ runner.os }}-py${{ inputs.python-version }}-${{ github.job }}-
conan-v2-${{ runner.os }}-py${{ inputs.python-version }}-
conan-${{ runner.os }}-py${{ inputs.python-version }}-

- name: Configure Conan network retries
shell: bash
run: python .github/scripts/configure_conan_retries.py

- name: Configure Conan profile
- name: Refresh Conan configuration
shell: bash
run: |
python -m conans.conan profile detect --exist-ok
prof_path="$(python -m conans.conan profile path default)"
grep -q '^\[conf\]' "$prof_path" || printf "\n[conf]\n" >> "$prof_path"
grep -q '^tools.system.package_manager:mode=install$' "$prof_path" || \
printf "tools.system.package_manager:mode=install\n" >> "$prof_path"
grep -q '^tools.system.package_manager:sudo=True$' "$prof_path" || \
printf "tools.system.package_manager:sudo=True\n" >> "$prof_path"
python .github/scripts/configure_conan_profile.py
python .github/scripts/configure_conan_retries.py

- name: Build Basilisk
shell: bash
Expand All @@ -107,3 +122,20 @@ runs:
pip install --no-build-isolation -e ".[examples]" -v
fi
bskLargeData

- name: Save Conan cache
if: ${{ success() && inputs.skip-build == 'false' && inputs.save-conan-cache == 'true' && steps.conan-cache.outcome == 'success' && steps.conan-cache.outputs.cache-hit != 'true' }}
continue-on-error: true
uses: actions/cache/save@v5
with:
path: ~/.conan2
key: ${{ steps.conan-cache.outputs.cache-primary-key }}

- name: Show sccache statistics
if: ${{ always() }}
continue-on-error: true
shell: bash
run: |
if command -v sccache >/dev/null 2>&1; then
sccache --show-stats
fi
2 changes: 1 addition & 1 deletion .github/actions/docs/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ runs:
env:
MPLBACKEND: agg
working-directory: src
run: pytest -n auto -m "not ciSkip" -rs --dist=loadscope -v
run: pytest -n auto -m "not ciSkip" -rs --dist=loadscope --durations=25 -v
- name: Compile docs
shell: bash
run: |
Expand Down
158 changes: 158 additions & 0 deletions .github/scripts/configure_conan_profile.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
# ISC License
#
# Copyright (c) 2026, Autonomous Vehicle Systems Lab, University of Colorado at Boulder
#
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
#
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
#

import argparse
import hashlib
import json
import os
from pathlib import Path
import subprocess
import sys
from typing import Optional


PROFILE_CONF_SETTINGS = {
"tools.system.package_manager:mode": "install",
"tools.system.package_manager:sudo": "True",
}


def _run_conan(*args: str, capture_output: bool = False) -> subprocess.CompletedProcess:
return subprocess.run(
[sys.executable, "-m", "conans.conan", *args],
check=True,
capture_output=capture_output,
text=True,
)


def _profile_setting_key(line: str) -> Optional[str]:
stripped = line.strip()
if not stripped or stripped.startswith("#") or "=" not in stripped:
return None
return stripped.split("=", 1)[0].strip()


def update_profile_conf(profile_text: str) -> str:
"""Set the required package-manager configuration in a Conan profile."""
lines = profile_text.splitlines()
updated_lines = []
seen_keys = set()
in_conf_section = False
found_conf_section = False

def append_missing_settings() -> None:
for key, value in PROFILE_CONF_SETTINGS.items():
if key not in seen_keys:
updated_lines.append(f"{key}={value}")
seen_keys.add(key)

for line in lines:
stripped = line.strip()
if stripped.startswith("[") and stripped.endswith("]"):
if in_conf_section:
append_missing_settings()
in_conf_section = stripped == "[conf]"
found_conf_section = found_conf_section or in_conf_section
updated_lines.append(line)
continue

if in_conf_section:
key = _profile_setting_key(line)
if key in PROFILE_CONF_SETTINGS:
if key not in seen_keys:
updated_lines.append(f"{key}={PROFILE_CONF_SETTINGS[key]}")
seen_keys.add(key)
continue
updated_lines.append(line)

if in_conf_section:
append_missing_settings()
elif not found_conf_section:
if updated_lines and updated_lines[-1].strip():
updated_lines.append("")
updated_lines.append("[conf]")
append_missing_settings()

return "\n".join(updated_lines) + "\n"


def configure_default_profile() -> Path:
"""Detect and configure the default Conan profile for the current runner."""
_run_conan("profile", "detect", "--force")
result = _run_conan("profile", "path", "default", capture_output=True)
output_lines = [line.strip() for line in result.stdout.splitlines() if line.strip()]
if not output_lines:
raise RuntimeError("Conan did not report the default profile path")

profile_path = Path(output_lines[-1])
profile_text = profile_path.read_text(encoding="utf-8")
profile_path.write_text(update_profile_conf(profile_text), encoding="utf-8")
print(f"Configured Conan profile at {profile_path}")
return profile_path


def cache_fingerprint(
profile_text: str,
conan_version: str,
runner_arch: str,
) -> str:
"""Return a stable cache fingerprint for a Conan toolchain."""
context = {
"conanProfile": profile_text,
"conanVersion": conan_version,
"runnerArch": runner_arch,
}
serialized_context = json.dumps(context, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(serialized_context.encode("utf-8")).hexdigest()


def _conan_version() -> str:
result = _run_conan("--version", capture_output=True)
return result.stdout.strip()


def _write_github_output(name: str, value: str) -> None:
output_path = os.environ.get("GITHUB_OUTPUT")
if not output_path:
raise RuntimeError("GITHUB_OUTPUT is required when emitting the cache fingerprint")
with Path(output_path).open("a", encoding="utf-8") as output_file:
output_file.write(f"{name}={value}\n")


def main() -> None:
parser = argparse.ArgumentParser(description="Configure Conan for a Basilisk CI build")
parser.add_argument(
"--emit-cache-fingerprint",
action="store_true",
help="write the current Conan build fingerprint to GITHUB_OUTPUT",
)
args = parser.parse_args()

profile_path = configure_default_profile()
if args.emit_cache_fingerprint:
fingerprint = cache_fingerprint(
profile_path.read_text(encoding="utf-8"),
_conan_version(),
os.environ.get("RUNNER_ARCH", ""),
)
_write_github_output("fingerprint", fingerprint)
print(f"Conan cache fingerprint: {fingerprint}")


if __name__ == "__main__":
main()
28 changes: 28 additions & 0 deletions .github/workflows/merge.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,33 @@ concurrency:
cancel-in-progress: false

jobs:
# The documentation job below is the macOS cache producer.
warm_build_caches:
name: ${{ matrix.name }} Build Cache
timeout-minutes: 105
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- name: Linux
os: ubuntu-24.04
conan-args: --opNav True --mujoco True
- name: Windows
os: windows-2025-vs2026
conan-args: --opNav True --mujoco True --generator Ninja
steps:
- uses: actions/checkout@v5
with:
ref: ${{ github.ref }}
- uses: ./.github/actions/data-cache
- name: Build Basilisk
uses: ./.github/actions/build
with:
python-version: 3.14
conan-args: ${{ matrix.conan-args }}
save-conan-cache: true

build_documentation:
name: macOS Docs Deployment
runs-on: macos-latest
Expand All @@ -28,6 +55,7 @@ jobs:
with:
python-version: 3.14
conan-args: --opNav True --allOptPkg --mujoco True
save-conan-cache: true
- name: Build docs
uses: ./.github/actions/docs
- name: Deploy non-develop branch merges to /
Expand Down
12 changes: 8 additions & 4 deletions .github/workflows/pull-request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ concurrency:
cancel-in-progress: true

jobs:
# One full job per platform publishes Conan dependencies. Other jobs restore only.
pre-commit:
runs-on: ubuntu-24.04
steps:
Expand All @@ -27,11 +28,12 @@ jobs:
with:
python-version: 3.14
conan-args: --opNav True --mujoco True
save-conan-cache: true
- name: Pytest
working-directory: src
run: |
pip install pytest-error-for-skips pytest-timeout
pytest -n auto -m "not ciSkip" -rs --error-for-skips --timeout=300 --timeout-method=thread -v
pytest -n auto -m "not ciSkip" -rs --error-for-skips --timeout=300 --timeout-method=thread --durations=25 -v
- name: CTest
if: ${{ always() && hashFiles('dist3/CTestTestfile.cmake') != '' }}
working-directory: dist3
Expand All @@ -51,12 +53,13 @@ jobs:
with:
python-version: 3.14
conan-args: --opNav True --mujoco True --generator Ninja
save-conan-cache: true
- name: Pytest
shell: pwsh
working-directory: src
run: |
pip install pytest-error-for-skips pytest-timeout
pytest -n auto -m "not ciSkip" -rs --error-for-skips --timeout=300 --timeout-method=thread -v
pytest -n auto -m "not ciSkip" -rs --error-for-skips --timeout=300 --timeout-method=thread --durations=25 -v
if(($LastExitCode -ne 0) -and ($LastExitCode -ne 5)) {exit 1}
- name: CTest
if: ${{ always() && hashFiles('dist3/CTestTestfile.cmake') != '' }}
Expand All @@ -78,14 +81,14 @@ jobs:
python: ["3.9"]
pytest_flags:
[
'-n auto -m "not ciSkip" -rs --error-for-skips --timeout=300 --timeout-method=thread -v',
'-n auto -m "not ciSkip" -rs --error-for-skips --timeout=300 --timeout-method=thread --durations=25 -v',
]
conan_args: ["--opNav True --mujoco True"]
include:
# An extra Basilisk build to test building without visualization enabled
- python: "3.14"
python_label: "MAX Python"
pytest_flags: -n auto -m "not ciSkip" -rs --timeout=300 --timeout-method=thread -v
pytest_flags: -n auto -m "not ciSkip" -rs --timeout=300 --timeout-method=thread --durations=25 -v
conan_args: --opNav True --mujoco True --vizInterface False
job_suffix: "--vizInterface False"

Expand Down Expand Up @@ -117,5 +120,6 @@ jobs:
with:
python-version: 3.14
conan-args: --opNav True --allOptPkg --mujoco True
save-conan-cache: true
- name: Build docs
uses: ./.github/actions/docs
10 changes: 10 additions & 0 deletions conanfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,16 @@ def generate(self):
tc.cache_variables["BUILD_OPNAV"] = bool(self.options.get_safe("opNav"))
tc.cache_variables["BUILD_VIZINTERFACE"] = bool(self.options.get_safe("vizInterface"))
tc.cache_variables["BUILD_MUJOCO"] = bool(self.options.get_safe("mujoco"))
tc.cache_variables["BSK_CONAN_BUILD_TYPE"] = str(self.settings.build_type)
tc.cache_variables["BSK_VERSION"] = str(self.version).strip()
tc.cache_variables["BSK_CONAN_VERSION"] = importlib.metadata.version("conan")
tc.cache_variables["BSK_CONAN_CXX_STANDARD"] = str(self.settings.get_safe("compiler.cppstd") or "")
tc.cache_variables["BSK_CONAN_CXX_STANDARD_LIBRARY"] = str(self.settings.get_safe("compiler.libcxx") or "")
tc.cache_variables["BSK_CONAN_COMPILER_RUNTIME"] = str(self.settings.get_safe("compiler.runtime") or "")
tc.cache_variables["BSK_CONAN_COMPILER_RUNTIME_TYPE"] = str(
self.settings.get_safe("compiler.runtime_type") or ""
)
tc.cache_variables["Python3_EXECUTABLE"] = Path(sys.executable).as_posix()
if self.options.get_safe("pathToExternalModules"):
tc.cache_variables["EXTERNAL_MODULES_PATH"] = Path(str(self.options.pathToExternalModules)).resolve().as_posix()
tc.cache_variables["PYTHON_VERSION"] = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
Expand Down
Loading
Loading