Nightly · main #91
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" |