Skip to content

Nightly · main

Nightly · main #91

Workflow file for this run

name: Nightly Build & Test
run-name: "${{ github.event_name == 'schedule' && 'Nightly' || startsWith(github.ref, 'refs/heads/release/') && 'Release candidate' || 'Manual build' }} · ${{ github.ref_name }}"
on:
schedule:
- cron: '30 2 * * *'
workflow_dispatch:
inputs:
force_build:
type: boolean
default: false
description: 'Force another full build even if this revision already passed'
concurrency:
group: nightly-${{ github.ref }}
cancel-in-progress: true
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
TEST_RESULTS_ARTIFACT: test-results-staging
BENCHMARK_RESULTS_ARTIFACT: benchmark-results-staging
EVAL_KPIS_ARTIFACT: eval-kpis-staging
EVAL_REPORT_ARTIFACT: eval-reports-staging
AWS_DEFAULT_REGION: ${{ vars.AWS_DEFAULT_REGION }}
S3_DATASETS_BUCKET: ${{ vars.S3_DATASETS_BUCKET }}
# Jetson configurations inherit this but never read it: their eval steps are
# gated on matrix.eval.
EVAL_SUITE: full
jobs:
check-changes:
name: Decide whether to build
runs-on: ubuntu-latest
permissions:
checks: read
contents: read
outputs:
should_build: ${{ steps.decide.outputs.should_build }}
is_release: ${{ steps.decide.outputs.is_release }}
release_tag: ${{ steps.decide.outputs.release_tag }}
package_version: ${{ steps.decide.outputs.package_version }}
previous_full_url: ${{ steps.previous-full-run.outputs.url }}
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
persist-credentials: false
# Skipped runs use a different summary-job name, so this check name
# identifies only full attempts for the current revision.
- name: Find latest full result for this revision
id: previous-full-run
continue-on-error: true
uses: actions/github-script@v7
with:
script: |
try {
const checkRuns = await github.paginate(github.rest.checks.listForRef, {
owner: context.repo.owner,
repo: context.repo.repo,
ref: context.sha,
check_name: 'Nightly full build status & summary',
filter: 'all',
per_page: 100,
});
const currentRunPath = `/actions/runs/${context.runId}/`;
const timestamp = check => Date.parse(check.completed_at || '') || 0;
const latest = checkRuns
.filter(check => check.status === 'completed'
&& check.app && check.app.slug === 'github-actions'
&& !(check.details_url || '').includes(currentRunPath))
.sort((left, right) => timestamp(right) - timestamp(left))[0];
const result = latest ? latest.conclusion || 'unknown' : 'missing';
const url = latest ? (latest.details_url || '').replace(/\/job\/\d+$/, '') : '';
core.setOutput('result', result);
core.setOutput('url', url);
if (latest) {
core.info(`Latest full nightly result for ${context.sha}: ${result} (${url})`);
} else {
core.info(`No previous full nightly result found for ${context.sha}.`);
}
} catch (error) {
core.warning(`Unable to inspect previous full nightly results; defaulting to a build: ${error}`);
core.setOutput('result', 'unknown');
core.setOutput('url', '');
}
- name: Decide whether to build current revision
id: decide
if: always()
shell: bash
env:
FORCE_BUILD: ${{ github.event.inputs.force_build }}
PREVIOUS_FULL_RESULT: ${{ steps.previous-full-run.outputs.result }}
PREVIOUS_FULL_URL: ${{ steps.previous-full-run.outputs.url }}
run: |
set -euo pipefail
FORCE="${FORCE_BUILD:-false}"
IS_RELEASE=false
RELEASE_TAG=""
PACKAGE_VERSION=$(< VERSION)
if [[ ! "$PACKAGE_VERSION" =~ ^[0-9]+(\.[0-9]+){1,2}([.-][0-9A-Za-z]+)*$ ]]; then
echo "::error::VERSION must contain MAJOR.MINOR[.PATCH][-SUFFIX] (got '$PACKAGE_VERSION')."
exit 1
fi
if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ] && [[ "$GITHUB_REF_NAME" == release/* ]]; then
RELEASE_TAG="$GITHUB_REF_NAME"
RELEASE_TAG="${RELEASE_TAG#release/}"
if [[ ! "$RELEASE_TAG" =~ ^v[0-9]+(\.[0-9]+){1,2}([.-][0-9A-Za-z]+)*$ ]]; then
echo "::error::Release branch must be named release/vMAJOR.MINOR[.PATCH][-SUFFIX] (got '$GITHUB_REF_NAME')."
exit 1
fi
if [ "$RELEASE_TAG" != "v$PACKAGE_VERSION" ]; then
echo "::error::Release tag $RELEASE_TAG does not match VERSION=$PACKAGE_VERSION."
exit 1
fi
IS_RELEASE=true
fi
echo "is_release=$IS_RELEASE" >> "$GITHUB_OUTPUT"
echo "release_tag=$RELEASE_TAG" >> "$GITHUB_OUTPUT"
echo "package_version=$PACKAGE_VERSION" >> "$GITHUB_OUTPUT"
if [ "${FORCE}" = "true" ] || [ "$IS_RELEASE" = "true" ]; then
echo "should_build=true" >> "$GITHUB_OUTPUT"
echo "Building: force=${FORCE}, release=${IS_RELEASE}"
elif [ "${PREVIOUS_FULL_RESULT:-missing}" = "success" ] && [ -n "${PREVIOUS_FULL_URL:-}" ]; then
echo "should_build=false" >> "$GITHUB_OUTPUT"
echo "Skipping: this revision already passed a full nightly at ${PREVIOUS_FULL_URL}"
else
echo "should_build=true" >> "$GITHUB_OUTPUT"
echo "Building: latest full result for this revision is ${PREVIOUS_FULL_RESULT:-missing}"
fi
- name: Verify release target is available
if: steps.decide.outputs.is_release == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.decide.outputs.release_tag }}
run: |
set -euo pipefail
existing_release=$(
gh api --paginate "repos/${GITHUB_REPOSITORY}/releases?per_page=100" \
--jq ".[] | select(.tag_name == \"${TAG}\") | [.id, .draft, .html_url] | @tsv"
)
if [ -n "$existing_release" ]; then
echo "::error::A draft or published release already targets ${TAG}. Delete an obsolete draft explicitly before rebuilding; published releases must not be replaced."
echo "$existing_release"
exit 1
fi
existing_tag=$(
gh api "repos/${GITHUB_REPOSITORY}/git/matching-refs/tags/${TAG}" \
--jq ".[] | select(.ref == \"refs/tags/${TAG}\") | .ref"
)
if [ -n "$existing_tag" ]; then
echo "::error::Git tag ${TAG} already exists. Refusing to move or replace it."
exit 1
fi
build-and-test:
name: "${{ matrix.name }}"
needs: check-changes
if: needs.check-changes.outputs.should_build == 'true'
runs-on: ${{ fromJSON(matrix.runner) }}
timeout-minutes: ${{ matrix.job_timeout || 120 }}
strategy:
fail-fast: false
matrix:
include:
# x86_64 configs (CUDA/Ubuntu controlled by Docker build args)
# tools_tests is platform-independent, so one config runs it. It needs the
# CI tools image, which is only built for eval-enabled configs.
- { name: "x86_64 / cuda-12.6 / ubuntu-22.04", runner: '["gpu"]', cuda: "12.6.3", ubuntu: "22.04", archs: "75;80;86;89;90", platform: "x86_64", wheel: true, eval: true, docs: true, tools_tests: true }
- { name: "x86_64 / cuda-12.6 / ubuntu-24.04", runner: '["gpu"]', cuda: "12.6.3", ubuntu: "24.04", archs: "75;80;86;89;90", platform: "x86_64", wheel: true, eval: true }
- { name: "x86_64 / cuda-13.2 / ubuntu-22.04", runner: '["gpu"]', cuda: "13.2.0", ubuntu: "22.04", archs: "75;80;86;89;100;120;121", platform: "x86_64", wheel: true, eval: true }
- { name: "x86_64 / cuda-13.2 / ubuntu-24.04", runner: '["gpu"]', cuda: "13.2.0", ubuntu: "24.04", archs: "75;80;86;89;100;120;121", platform: "x86_64", wheel: true, eval: true }
# Non-default CMake configuration smoke build: builds + tests + wheel with extra
# cmake_options, but skips eval/docs and uploads no artifacts (skip_artifacts).
# Add another such row to cover a different option set (e.g. USE_CUDA).
- { name: "x86_64 / USE_RERUN (config smoke)", runner: '["gpu"]', cuda: "12.6.3", ubuntu: "24.04", archs: "75;80;86;89;90", platform: "x86_64-rerun", wheel: true, cmake_options: "-DUSE_RERUN=ON", skip_artifacts: true }
# Jetson configs (CUDA version must match host JetPack driver)
# Orin uses base_image override; cuda value is not used for the Docker base but kept for artifact naming
- { name: "aarch64 / Orin (JP 6.1)", runner: '["jetson-orin"]', cuda: "12.6.3", ubuntu: "22.04", archs: "87", platform: "orin", wheel: true, benchmark: true, base_image: "nvcr.io/nvidia/12.6.11-devel:12.6.11-devel-aarch64-ubuntu22.04", ports_mirror: "http://mirrors.ocf.berkeley.edu/ubuntu-ports" }
- { name: "aarch64 / Thor (JP 7.1)", runner: '["jetson-thor"]', cuda: "13.0.1", ubuntu: "24.04", archs: "110", platform: "thor", wheel: true, benchmark: true, retries: 3, build_timeout: 60, job_timeout: 180, ports_mirror: "http://mirrors.ocf.berkeley.edu/ubuntu-ports" }
steps:
- name: Verify GPU
run: nvidia-smi
- uses: actions/checkout@v5
with:
lfs: false
- name: Prepare Git checkout for Docker build
run: |
# Some runner filesystems normalize executable bits, and the LFS clean filter needs writable storage.
git config --local core.fileMode false
git config --local lfs.storage /tmp/cuvslam-lfs
git lfs pull --include="test_data/"
- name: Resolve runner storage root
if: matrix.eval && vars.RUNNER_STORAGE_ROOT != ''
run: echo "RUNNER_STORAGE_ROOT=${{ vars.RUNNER_STORAGE_ROOT }}" >> "$GITHUB_ENV"
- name: Resolve local datasets root
if: matrix.eval
run: echo "RUNNER_LOCAL_DATASETS_ROOT=${{ vars.RUNNER_LOCAL_DATASETS_ROOT || '$HOME/.cache/cuvslam' }}" >> "$GITHUB_ENV"
- name: Build CI tools image
if: matrix.eval
env:
AWS_CLI_PUBLIC_KEY: ${{ vars.AWS_CLI_PUBLIC_KEY }}
run: |
keyfile="$RUNNER_TEMP/aws-cli-public-key.asc"
if [ -z "${AWS_CLI_PUBLIC_KEY:-}" ]; then
echo "AWS_CLI_PUBLIC_KEY repository variable is not configured" >&2
exit 1
fi
printf '%s\n' "$AWS_CLI_PUBLIC_KEY" > "$keyfile"
DOCKER_BUILDKIT=1 docker build -f scripts/Dockerfile.ci . --network host --tag cuvslam-ci:local \
--secret id=aws_cli_public_key,src="$keyfile"
# Runs before the build so a broken tool fails in seconds. The package is
# copied out of the read-only mount before installing, as run_eval.sh does,
# to keep pip build artifacts out of the runner workspace.
- name: Run Python tools tests
if: matrix.tools_tests
run: |
docker run --rm --network host \
-v "$(pwd):/cuvslam:ro" \
cuvslam-ci:local \
bash -c 'set -euo pipefail
src="$(mktemp -d)"
cp -a /cuvslam/tools/python_tools/. "$src/"
pip install --quiet "$src"
PYTHONPATH="$src" python3 -m unittest discover -v -s "$src/cuvslam_tools/tests"'
- name: Verify eval prerequisites
if: matrix.eval
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_S3_RO_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_S3_RO_SECRET_ACCESS_KEY }}
run: |
docker run --rm --network host \
-e AWS_ACCESS_KEY_ID -e AWS_SECRET_ACCESS_KEY -e AWS_DEFAULT_REGION -e S3_DATASETS_BUCKET \
-e RUNNER_STORAGE_ROOT -e RUNNER_LOCAL_DATASETS_ROOT -e EVAL_SUITE \
-v "$(pwd):/cuvslam:ro" \
-v "$RUNNER_LOCAL_DATASETS_ROOT:$RUNNER_LOCAL_DATASETS_ROOT" \
-w /cuvslam \
cuvslam-ci:local ./scripts/check_eval_prerequisites.sh
- name: Stage eval datasets
if: matrix.eval
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_S3_RO_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_S3_RO_SECRET_ACCESS_KEY }}
run: |
mkdir -p "$RUNNER_LOCAL_DATASETS_ROOT"
docker run --rm --network host \
-e AWS_ACCESS_KEY_ID -e AWS_SECRET_ACCESS_KEY -e AWS_DEFAULT_REGION -e S3_DATASETS_BUCKET \
-e RUNNER_LOCAL_DATASETS_ROOT -e EVAL_SUITE \
-v "$(pwd):/cuvslam:ro" \
-v "$RUNNER_LOCAL_DATASETS_ROOT:$RUNNER_LOCAL_DATASETS_ROOT" \
-w /cuvslam \
cuvslam-ci:local ./scripts/stage_eval_datasets.sh
- name: Build C++ libraries
uses: nick-fields/retry@v3
with:
max_attempts: ${{ matrix.retries || 1 }}
timeout_minutes: ${{ matrix.build_timeout || 120 }}
retry_wait_seconds: 30
command: |
CUDA_VERSION=${{ matrix.cuda }} \
UBUNTU_VERSION=${{ matrix.ubuntu }} \
BASE_IMAGE="${{ matrix.base_image }}" \
UBUNTU_PORTS_MIRROR="${{ matrix.ports_mirror }}" \
EXTRA_CMAKE_ARGS="-DCMAKE_CUDA_ARCHITECTURES=${{ matrix.archs }} ${{ matrix.cmake_options }}" \
./scripts/build_cuvslam_in_docker.sh Release ./output
- name: Run C++ tests
uses: nick-fields/retry@v3
with:
max_attempts: ${{ matrix.retries || 1 }}
timeout_minutes: 30
retry_wait_seconds: 30
command: ./scripts/test_cuvslam_in_docker.sh ./output
# Collect benchmark data without gating nightly while Jetson variance is characterized.
- name: Run Jetson CUDA benchmarks
id: cuda-benchmarks
if: matrix.benchmark
continue-on-error: true
uses: nick-fields/retry@v3
with:
max_attempts: ${{ matrix.retries || 1 }}
timeout_minutes: 30
retry_wait_seconds: 30
command: |
BENCHMARK_CONFIG="${{ matrix.platform }}-cuda${{ matrix.cuda }}-ubuntu${{ matrix.ubuntu }}" \
CUDA_VERSION="${{ matrix.cuda }}" \
UBUNTU_VERSION="${{ matrix.ubuntu }}" \
./scripts/benchmark_cuvslam_in_docker.sh ./output
- name: Publish Jetson benchmark job summary
if: always() && matrix.benchmark
run: |
if [ -f output/benchmark-summary.md ]; then
{
echo "## Jetson CUDA Benchmarks"
echo ""
cat output/benchmark-summary.md
} >> "$GITHUB_STEP_SUMMARY"
else
echo "## Jetson CUDA Benchmarks" >> "$GITHUB_STEP_SUMMARY"
echo "No structured benchmark report was produced; inspect the raw benchmark artifact." \
>> "$GITHUB_STEP_SUMMARY"
fi
- name: Build Python wheel
if: matrix.wheel
run: |
CUDA_VERSION=${{ matrix.cuda }} \
UBUNTU_VERSION=${{ matrix.ubuntu }} \
./scripts/build_pycuvslam_in_docker.sh ./output
- name: Verify Python wheel installs
if: matrix.wheel
env:
PACKAGE_VERSION: ${{ needs.check-changes.outputs.package_version }}
run: |
./scripts/verify_pycuvslam_wheel_in_docker.sh \
./output "$PACKAGE_VERSION" "$(git rev-parse HEAD)"
- name: Run Python tests
uses: nick-fields/retry@v3
with:
max_attempts: ${{ matrix.retries || 1 }}
timeout_minutes: 30
retry_wait_seconds: 30
command: ./scripts/test_pycuvslam_in_docker.sh ./output
- name: Package C++ distribution
if: ${{ !matrix.skip_artifacts }}
env:
PACKAGE_VERSION: ${{ needs.check-changes.outputs.package_version }}
run: |
CONFIG="${{ matrix.platform }}-cuda${{ matrix.cuda }}-ubuntu${{ matrix.ubuntu }}"
./scripts/package_cpp_dist.sh \
./output \
"./output/dist/cuvslam-cpp-${PACKAGE_VERSION}-${CONFIG}.tar.gz"
- name: Generate test summary
if: always() && !matrix.skip_artifacts
run: |
CONFIG="${{ matrix.platform }}-cuda${{ matrix.cuda }}-ubuntu${{ matrix.ubuntu }}"
python3 scripts/junit-to-markdown.py "$CONFIG" C++ output/cpp-test-results.xml > output/test-summary.txt
python3 scripts/junit-to-markdown.py "$CONFIG" Python output/python-test-output.txt >> output/test-summary.txt
- name: Build documentation
if: matrix.docs
run: ./scripts/build_docs_in_docker.sh ./output
- name: Package documentation
if: matrix.docs
env:
PACKAGE_VERSION: ${{ needs.check-changes.outputs.package_version }}
run: |
mkdir -p output/dist
tar -czf "output/dist/cuvslam-docs-${PACKAGE_VERSION}.tar.gz" -C output/docs .
- name: Upload documentation
if: matrix.docs
uses: actions/upload-artifact@v7
with:
path: output/dist/cuvslam-docs-${{ needs.check-changes.outputs.package_version }}.tar.gz
archive: false
overwrite: true
retention-days: 30
if-no-files-found: error
- name: Run cuVSLAM evaluation
if: matrix.eval
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_S3_RO_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_S3_RO_SECRET_ACCESS_KEY }}
run: |
RUN_ID="$(date -u +%Y-%m-%d)" \
KPI_HISTORY_DIR="${RUNNER_STORAGE_ROOT:?RUNNER_STORAGE_ROOT must be set}/cuvslam-ci/kpi-history/${{ matrix.platform }}-cuda${{ matrix.cuda }}-ubuntu${{ matrix.ubuntu }}" \
./scripts/eval_cuvslam_in_docker.sh ./output
- name: Upload KPI results
if: always() && matrix.eval
uses: actions/upload-artifact@v7
with:
name: ${{ env.EVAL_KPIS_ARTIFACT }}-${{ needs.check-changes.outputs.package_version }}-${{ matrix.platform }}-cuda${{ matrix.cuda }}-ubuntu${{ matrix.ubuntu }}
path: output/eval/kpi_*.json
overwrite: true
retention-days: 1
if-no-files-found: warn
- name: Upload eval PDF reports for aggregation
if: always() && matrix.eval
uses: actions/upload-artifact@v7
with:
name: ${{ env.EVAL_REPORT_ARTIFACT }}-${{ needs.check-changes.outputs.package_version }}-${{ matrix.platform }}-cuda${{ matrix.cuda }}-ubuntu${{ matrix.ubuntu }}
path: output/eval/stats/**/report_*.pdf
overwrite: true
retention-days: 1
if-no-files-found: warn
- name: Upload test results
if: always() && !matrix.skip_artifacts
uses: actions/upload-artifact@v7
with:
name: ${{ env.TEST_RESULTS_ARTIFACT }}-${{ needs.check-changes.outputs.package_version }}-${{ matrix.platform }}-cuda${{ matrix.cuda }}-ubuntu${{ matrix.ubuntu }}
path: |
output/cpp-test-results.xml
output/python-test-output.txt
output/test-summary.txt
overwrite: true
retention-days: 1
if-no-files-found: warn
- name: Upload Jetson benchmark results
if: always() && matrix.benchmark
uses: actions/upload-artifact@v7
with:
name: ${{ env.BENCHMARK_RESULTS_ARTIFACT }}-${{ needs.check-changes.outputs.package_version }}-${{ matrix.platform }}-cuda${{ matrix.cuda }}-ubuntu${{ matrix.ubuntu }}
path: |
output/cpp-benchmark-metadata.json
output/cpp-benchmark-output.log
output/cpp-benchmark-results.json
output/cpp-benchmark-results.xml
output/benchmark-summary.md
overwrite: true
retention-days: 1
if-no-files-found: warn
- name: Upload C++ distribution
if: ${{ !matrix.skip_artifacts }}
uses: actions/upload-artifact@v7
with:
path: output/dist/cuvslam-cpp-${{ needs.check-changes.outputs.package_version }}-${{ matrix.platform }}-cuda${{ matrix.cuda }}-ubuntu${{ matrix.ubuntu }}.tar.gz
archive: false
overwrite: true
retention-days: 30
if-no-files-found: error
- name: Upload Python wheel
if: matrix.wheel && !matrix.skip_artifacts
uses: actions/upload-artifact@v7
with:
path: output/wheel/*.whl
archive: false
overwrite: true
retention-days: 30
if-no-files-found: error
nightly-summary:
name: >-
${{
needs.check-changes.result != 'success' && 'Nightly preflight failed' ||
needs.check-changes.outputs.should_build == 'true' && 'Nightly full build status & summary' ||
'Nightly skipped — revision already passed'
}}
runs-on: ubuntu-latest
if: always()
needs: [check-changes, build-and-test]
outputs:
release_cpp_count: ${{ steps.finalize-artifacts.outputs.cpp_count }}
release_wheel_count: ${{ steps.finalize-artifacts.outputs.wheel_count }}
permissions:
actions: write # Required by github.rest.actions.deleteArtifact below.
contents: read
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Download test result artifacts
if: >-
needs.check-changes.outputs.should_build == 'true' &&
needs.build-and-test.result == 'success'
continue-on-error: true
uses: actions/download-artifact@v8
with:
pattern: ${{ env.TEST_RESULTS_ARTIFACT }}-${{ needs.check-changes.outputs.package_version }}-*
path: artifacts/test-results
- name: Download Jetson benchmark staging artifacts
if: >-
needs.check-changes.outputs.should_build == 'true' &&
needs.build-and-test.result == 'success'
continue-on-error: true
uses: actions/download-artifact@v8
with:
pattern: ${{ env.BENCHMARK_RESULTS_ARTIFACT }}-${{ needs.check-changes.outputs.package_version }}-*
path: artifacts/benchmark-results
- name: Download KPI staging artifacts
if: >-
needs.check-changes.outputs.should_build == 'true' &&
needs.build-and-test.result == 'success'
continue-on-error: true
uses: actions/download-artifact@v8
with:
pattern: ${{ env.EVAL_KPIS_ARTIFACT }}-${{ needs.check-changes.outputs.package_version }}-*
path: artifacts/kpis
- name: Download evaluation report staging artifacts
if: >-
needs.check-changes.outputs.should_build == 'true' &&
needs.build-and-test.result == 'success'
continue-on-error: true
uses: actions/download-artifact@v8
with:
pattern: ${{ env.EVAL_REPORT_ARTIFACT }}-${{ needs.check-changes.outputs.package_version }}-*
path: artifacts/eval-reports
- name: Build combined summary
id: aggregate
shell: bash
env:
PACKAGE_VERSION: ${{ needs.check-changes.outputs.package_version }}
PREVIOUS_FULL_URL: ${{ needs.check-changes.outputs.previous_full_url }}
run: |
set -euo pipefail
mkdir -p consolidated/test-results consolidated/benchmark-results consolidated/kpis consolidated/eval-reports
REPORT=consolidated/summary.md
{
echo "# cuVSLAM CI Summary"
echo ""
echo "- Trigger: \`$GITHUB_EVENT_NAME\`"
echo "- Ref: \`$GITHUB_REF_NAME\`"
echo "- Commit: \`$GITHUB_SHA\`"
echo "- Matrix result: \`${{ needs.build-and-test.result }}\`"
echo ""
} > "$REPORT"
if [ "${{ needs.check-changes.result }}" != "success" ]; then
{
echo "The preflight job failed before the build decision could be made."
echo ""
} >> "$REPORT"
echo "has_test_results=false" >> "$GITHUB_OUTPUT"
echo "has_benchmark_results=false" >> "$GITHUB_OUTPUT"
echo "has_reports=false" >> "$GITHUB_OUTPUT"
exit 0
fi
if [ "${{ needs.check-changes.outputs.should_build }}" != "true" ]; then
{
echo "Nightly skipped because this revision already passed a full nightly."
echo "Previous successful full run: [view run](${PREVIOUS_FULL_URL})"
echo ""
} >> "$REPORT"
echo "has_test_results=false" >> "$GITHUB_OUTPUT"
echo "has_benchmark_results=false" >> "$GITHUB_OUTPUT"
echo "has_reports=false" >> "$GITHUB_OUTPUT"
exit 0
fi
if [ "${{ needs.build-and-test.result }}" != "success" ]; then
{
echo "Artifact consolidation is deferred until all matrix jobs succeed."
echo "Per-configuration staging artifacts are retained for selective job reruns."
echo ""
} >> "$REPORT"
echo "has_test_results=false" >> "$GITHUB_OUTPUT"
echo "has_benchmark_results=false" >> "$GITHUB_OUTPUT"
echo "has_reports=false" >> "$GITHUB_OUTPUT"
exit 0
fi
test_config_count=0
TEST_ARTIFACT_PREFIX="${TEST_RESULTS_ARTIFACT}-${PACKAGE_VERSION}-"
for testdir in artifacts/test-results/${TEST_ARTIFACT_PREFIX}*; do
[ -d "$testdir" ] || continue
CONFIG=${testdir#artifacts/test-results/${TEST_ARTIFACT_PREFIX}}
mkdir -p "consolidated/test-results/$CONFIG"
cp -a "$testdir"/. "consolidated/test-results/$CONFIG/"
test_config_count=$((test_config_count + 1))
done
if [ "$test_config_count" -gt 0 ]; then
echo "has_test_results=true" >> "$GITHUB_OUTPUT"
else
echo "has_test_results=false" >> "$GITHUB_OUTPUT"
fi
{
echo "## Test Results"
echo ""
echo "| Status | Configuration | Language | Total | Passed | Failed | Errors | Skipped |"
echo "|:------:|---------------|----------|------:|-------:|-------:|-------:|--------:|"
} >> "$REPORT"
for summary in consolidated/test-results/*/test-summary.txt; do
[ -f "$summary" ] || continue
while IFS='|' read -r status config language total passed failed errors skipped _details; do
case "$status" in
pass) icon="PASS" ;;
fail) icon="FAIL" ;;
*) icon="NO RESULTS" ;;
esac
echo "| $icon | $config | $language | $total | $passed | $failed | $errors | $skipped |" >> "$REPORT"
done < "$summary"
done
echo "" >> "$REPORT"
benchmark_config_count=0
benchmark_incomplete_count=0
BENCHMARK_ARTIFACT_PREFIX="${BENCHMARK_RESULTS_ARTIFACT}-${PACKAGE_VERSION}-"
for benchmarkdir in artifacts/benchmark-results/${BENCHMARK_ARTIFACT_PREFIX}*; do
[ -d "$benchmarkdir" ] || continue
CONFIG=${benchmarkdir#artifacts/benchmark-results/${BENCHMARK_ARTIFACT_PREFIX}}
mkdir -p "consolidated/benchmark-results/$CONFIG"
cp -a "$benchmarkdir"/. "consolidated/benchmark-results/$CONFIG/"
BENCHMARK_JSON=$(find "$benchmarkdir" -name "cpp-benchmark-results.json" -print -quit 2>/dev/null || true)
BENCHMARK_SUMMARY=$(find "$benchmarkdir" -name "benchmark-summary.md" -print -quit 2>/dev/null || true)
if [ ! -f "$BENCHMARK_JSON" ] || [ ! -f "$BENCHMARK_SUMMARY" ]; then
benchmark_incomplete_count=$((benchmark_incomplete_count + 1))
continue
fi
if ! python3 -c \
'import json,sys; sys.exit(0 if json.load(open(sys.argv[1]))["summary"]["complete"] else 1)' \
"$BENCHMARK_JSON"; then
benchmark_incomplete_count=$((benchmark_incomplete_count + 1))
fi
benchmark_config_count=$((benchmark_config_count + 1))
done
if [ "$benchmark_config_count" -gt 0 ]; then
echo "has_benchmark_results=true" >> "$GITHUB_OUTPUT"
{
echo "Jetson CUDA benchmark reports: $benchmark_config_count collected; detailed tables are in the Orin and Thor job summaries."
echo ""
} >> "$REPORT"
else
echo "has_benchmark_results=false" >> "$GITHUB_OUTPUT"
fi
kpi_config_count=0
kpi_inputs=()
KPI_ARTIFACT_PREFIX="${EVAL_KPIS_ARTIFACT}-${PACKAGE_VERSION}-"
for kpidir in artifacts/kpis/${KPI_ARTIFACT_PREFIX}*; do
[ -d "$kpidir" ] || continue
CONFIG=${kpidir#artifacts/kpis/${KPI_ARTIFACT_PREFIX}}
mkdir -p "consolidated/kpis/$CONFIG"
cp -a "$kpidir"/. "consolidated/kpis/$CONFIG/"
KPI_REPORT_JSON=$(find "$kpidir" -name "kpi_*.report.json" -print -quit 2>/dev/null || true)
[ -f "$KPI_REPORT_JSON" ] || continue
kpi_inputs+=(--input "$CONFIG=$KPI_REPORT_JSON")
kpi_config_count=$((kpi_config_count + 1))
done
if [ "$kpi_config_count" -gt 0 ]; then
python3 scripts/cuvslam_kpi_report.py aggregate \
"${kpi_inputs[@]}" \
--output consolidated/kpi-combined.md
{
echo "## cuVSLAM Evaluation KPIs"
echo ""
cat consolidated/kpi-combined.md
echo ""
} >> "$REPORT"
fi
report_config_count=0
REPORT_ARTIFACT_PREFIX="${EVAL_REPORT_ARTIFACT}-${PACKAGE_VERSION}-"
for repdir in artifacts/eval-reports/${REPORT_ARTIFACT_PREFIX}*; do
[ -d "$repdir" ] || continue
CONFIG=${repdir#artifacts/eval-reports/${REPORT_ARTIFACT_PREFIX}}
PDF=$(find "$repdir" -name "report_*.pdf" -print -quit 2>/dev/null || true)
[ -f "$PDF" ] || continue
mkdir -p "consolidated/eval-reports/$CONFIG"
cp -a "$repdir"/. "consolidated/eval-reports/$CONFIG/"
report_config_count=$((report_config_count + 1))
done
if [ "$report_config_count" -gt 0 ]; then
echo "has_reports=true" >> "$GITHUB_OUTPUT"
else
echo "has_reports=false" >> "$GITHUB_OUTPUT"
fi
if [ "${{ needs.build-and-test.result }}" = "success" ]; then
if [ "$test_config_count" -eq 0 ]; then
echo "::error::Successful matrix produced no test result artifacts."
exit 1
fi
if [ "$benchmark_config_count" -ne 2 ]; then
echo "::error::Expected benchmark reports from Orin and Thor; got $benchmark_config_count."
exit 1
fi
if [ "$benchmark_incomplete_count" -ne 0 ]; then
echo "::error::$benchmark_incomplete_count Jetson benchmark artifact(s) were incomplete."
exit 1
fi
if [ "$kpi_config_count" -eq 0 ]; then
echo "::error::Successful matrix produced no KPI artifacts."
exit 1
fi
if [ "$report_config_count" -ne "$kpi_config_count" ]; then
echo "::error::Expected one PDF report artifact for each KPI configuration; got $report_config_count reports and $kpi_config_count KPI sets."
exit 1
fi
fi
- name: Upload consolidated test results
id: upload-tests
if: steps.aggregate.outputs.has_test_results == 'true'
uses: actions/upload-artifact@v7
with:
name: test-results-${{ needs.check-changes.outputs.package_version }}
path: consolidated/test-results/
overwrite: true
retention-days: 30
if-no-files-found: error
- name: Upload consolidated Jetson benchmark results
id: upload-benchmarks
if: steps.aggregate.outputs.has_benchmark_results == 'true'
uses: actions/upload-artifact@v7
with:
name: benchmark-results-${{ needs.check-changes.outputs.package_version }}
path: consolidated/benchmark-results/
overwrite: true
retention-days: 30
if-no-files-found: error
- name: Package evaluation record
if: steps.aggregate.outputs.has_reports == 'true'
env:
PACKAGE_VERSION: ${{ needs.check-changes.outputs.package_version }}
run: |
set -euo pipefail
EVALUATION_SUMMARY=consolidated/evaluation-summary.md
{
echo "# cuVSLAM ${PACKAGE_VERSION}"
echo ""
echo "## Evaluation KPIs"
echo ""
cat consolidated/kpi-combined.md
echo ""
echo "## Evaluation record"
echo ""
echo "The attached \`cuvslam-evaluation-${PACKAGE_VERSION}.tar.gz\` contains the KPI data and PDF evaluation reports used to verify this release."
} > "$EVALUATION_SUMMARY"
tar -czf "consolidated/cuvslam-evaluation-${PACKAGE_VERSION}.tar.gz" \
-C consolidated \
evaluation-summary.md \
kpi-combined.md \
kpis \
eval-reports
- name: Upload consolidated evaluation record
id: upload-evaluation
if: steps.aggregate.outputs.has_reports == 'true'
uses: actions/upload-artifact@v7
with:
path: consolidated/cuvslam-evaluation-${{ needs.check-changes.outputs.package_version }}.tar.gz
archive: false
overwrite: true
retention-days: 30
if-no-files-found: error
- name: Delete staging artifacts
id: finalize-artifacts
if: always() && needs.build-and-test.result == 'success'
uses: actions/github-script@v7
env:
PACKAGE_VERSION: ${{ needs.check-changes.outputs.package_version }}
TESTS_RETAINED: ${{ steps.upload-tests.outcome == 'success' }}
BENCHMARKS_RETAINED: ${{ steps.upload-benchmarks.outcome == 'success' }}
EVALUATION_RETAINED: ${{ steps.upload-evaluation.outcome == 'success' }}
with:
script: |
const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, {
owner: context.repo.owner,
repo: context.repo.repo,
run_id: context.runId,
per_page: 100,
});
const cppCount = artifacts.filter(
artifact => artifact.name.startsWith(`cuvslam-cpp-${process.env.PACKAGE_VERSION}-`)
&& artifact.name.endsWith('.tar.gz'),
).length;
const wheelCount = artifacts.filter(
artifact => artifact.name.startsWith('cuvslam-') && artifact.name.endsWith('.whl'),
).length;
core.setOutput('cpp_count', cppCount.toString());
core.setOutput('wheel_count', wheelCount.toString());
const prefixes = [];
if (process.env.TESTS_RETAINED === 'true') {
prefixes.push(`${process.env.TEST_RESULTS_ARTIFACT}-${process.env.PACKAGE_VERSION}-`);
}
if (process.env.BENCHMARKS_RETAINED === 'true') {
prefixes.push(`${process.env.BENCHMARK_RESULTS_ARTIFACT}-${process.env.PACKAGE_VERSION}-`);
}
if (process.env.EVALUATION_RETAINED === 'true') {
prefixes.push(
`${process.env.EVAL_KPIS_ARTIFACT}-${process.env.PACKAGE_VERSION}-`,
`${process.env.EVAL_REPORT_ARTIFACT}-${process.env.PACKAGE_VERSION}-`,
);
}
for (const artifact of artifacts.filter(a => prefixes.some(prefix => a.name.startsWith(prefix)))) {
await github.rest.actions.deleteArtifact({
owner: context.repo.owner,
repo: context.repo.repo,
artifact_id: artifact.id,
});
}
- name: Publish workflow summary
if: always()
run: |
if [ -f consolidated/summary.md ]; then
cat consolidated/summary.md >> "$GITHUB_STEP_SUMMARY"
else
echo "## cuVSLAM CI Summary" >> "$GITHUB_STEP_SUMMARY"
echo "Summary generation failed. Inspect the job logs." >> "$GITHUB_STEP_SUMMARY"
fi
- name: Check overall result
if: always()
env:
PREVIOUS_FULL_URL: ${{ needs.check-changes.outputs.previous_full_url }}
run: |
if [ "${{ needs.check-changes.result }}" != "success" ]; then
echo "::error::The build preflight job did not succeed: ${{ needs.check-changes.result }}"
exit 1
elif [ "${{ needs.check-changes.outputs.should_build }}" = "false" ]; then
echo "Nightly skipped: this revision already passed at ${PREVIOUS_FULL_URL}."
elif [ "${{ needs.build-and-test.result }}" != "success" ]; then
echo "::error::One or more build-and-test matrix jobs did not succeed: ${{ needs.build-and-test.result }}"
exit 1
fi
publish-release:
name: Create draft release
runs-on: ubuntu-latest
if: >-
always() &&
needs.check-changes.outputs.is_release == 'true' &&
needs.build-and-test.result == 'success' &&
needs.nightly-summary.result == 'success'
needs: [check-changes, build-and-test, nightly-summary]
permissions:
actions: read
contents: write # Required by gh release create below.
steps:
# Recheck after the matrix to catch a release or tag created since preflight.
- name: Recheck release target is available
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ needs.check-changes.outputs.release_tag }}
run: |
set -euo pipefail
existing_release=$(
gh api --paginate "repos/${GITHUB_REPOSITORY}/releases?per_page=100" \
--jq ".[] | select(.tag_name == \"${TAG}\") | [.id, .draft, .html_url] | @tsv"
)
if [ -n "$existing_release" ]; then
echo "::error::A draft or published release already targets ${TAG}. Delete an obsolete draft explicitly before rebuilding; published releases must not be replaced."
echo "$existing_release"
exit 1
fi
existing_tag=$(
gh api "repos/${GITHUB_REPOSITORY}/git/matching-refs/tags/${TAG}" \
--jq ".[] | select(.ref == \"refs/tags/${TAG}\") | .ref"
)
if [ -n "$existing_tag" ]; then
echo "::error::Git tag ${TAG} already exists. Refusing to move or replace it."
exit 1
fi
- name: Download release artifacts
uses: actions/download-artifact@v8
with:
pattern: cuvslam-*
path: release-assets
merge-multiple: true
- name: Validate release assets and prepare notes
env:
PACKAGE_VERSION: ${{ needs.check-changes.outputs.package_version }}
EXPECTED_CPP_COUNT: ${{ needs.nightly-summary.outputs.release_cpp_count }}
EXPECTED_WHEEL_COUNT: ${{ needs.nightly-summary.outputs.release_wheel_count }}
run: |
set -euo pipefail
shopt -s nullglob
WHEEL_VERSION=$(
python3 -c '
import sys
try:
from packaging.version import Version
except ModuleNotFoundError:
from pip._vendor.packaging.version import Version
print(Version(sys.argv[1]))
' "$PACKAGE_VERSION"
)
cpp_assets=(release-assets/cuvslam-cpp-"${PACKAGE_VERSION}"-*.tar.gz)
wheel_assets=(release-assets/cuvslam-"${WHEEL_VERSION}"+cu*.whl)
docs_asset="release-assets/cuvslam-docs-${PACKAGE_VERSION}.tar.gz"
evaluation_asset="release-assets/cuvslam-evaluation-${PACKAGE_VERSION}.tar.gz"
if [[ ! "$EXPECTED_CPP_COUNT" =~ ^[1-9][0-9]*$ ]] || [[ ! "$EXPECTED_WHEEL_COUNT" =~ ^[1-9][0-9]*$ ]] ||
[ "${#cpp_assets[@]}" -ne "$EXPECTED_CPP_COUNT" ] ||
[ "${#wheel_assets[@]}" -ne "$EXPECTED_WHEEL_COUNT" ]; then
echo "::error::Expected $EXPECTED_CPP_COUNT C++ archives and $EXPECTED_WHEEL_COUNT Python wheels; got ${#cpp_assets[@]} and ${#wheel_assets[@]}."
exit 1
fi
if [ ! -f "$docs_asset" ] || [ ! -f "$evaluation_asset" ]; then
echo "::error::Versioned documentation or evaluation asset is missing."
exit 1
fi
assets=(release-assets/*)
if [ "${#assets[@]}" -eq 0 ]; then
echo "::error::No release assets were downloaded."
exit 1
fi
for asset in "${assets[@]}"; do
expected_version=$PACKAGE_VERSION
if [[ "$asset" == *.whl ]]; then
expected_version=$WHEEL_VERSION
fi
if [[ "$(basename "$asset")" != *"$expected_version"* ]]; then
echo "::error::Release asset does not contain version $expected_version: $asset"
exit 1
fi
done
tar -xOf "$evaluation_asset" evaluation-summary.md > release-notes.md
- name: Create draft GitHub Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
TAG: ${{ needs.check-changes.outputs.release_tag }}
run: |
set -euo pipefail
shopt -s nullglob
assets=(release-assets/*)
draft_url=$(
gh release create "$TAG" \
--target "$GITHUB_SHA" \
--title "cuVSLAM ${TAG}" \
--notes-file release-notes.md \
--draft \
"${assets[@]}"
)
{
echo "## Draft release created"
echo ""
echo "[$TAG]($draft_url)"
echo ""
echo "To rebuild this release candidate, delete the draft explicitly and dispatch the updated release branch again."
} >> "$GITHUB_STEP_SUMMARY"