From 27e2dbaf406928fe84d9ebc354bb667177b1407b Mon Sep 17 00:00:00 2001 From: Warren Snipes Date: Tue, 27 Jan 2026 14:32:35 -0500 Subject: [PATCH 1/3] Upgrade to trixie, use slim in image --- Dockerfile | 136 ++++++++++++++++++++++++----------------------------- 1 file changed, 62 insertions(+), 74 deletions(-) diff --git a/Dockerfile b/Dockerfile index 4883c4c..7b2ed46 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,16 +1,13 @@ -FROM debian:bullseye as dependencies +# Default build produces the "runtime" stage (slim). Use --target gtsam for a dev image with build tools and shell. +FROM debian:trixie-20260112 AS dependencies ARG PYTHON_VERSION=3.11.2 # Disable GUI prompts -ENV DEBIAN_FRONTEND noninteractive +ENV DEBIAN_FRONTEND=noninteractive - -RUN rm /var/lib/dpkg/info/libc-bin.* -RUN apt-get clean && apt-get update -RUN apt-get -y install libc-bin - -# Install required build dependencies -RUN apt-get update && apt-get install -y \ +# Install required build dependencies (single update for better layer caching) +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ build-essential \ wget \ libssl-dev \ @@ -33,56 +30,9 @@ RUN apt-get update && apt-get install -y \ libmpc-dev \ libmpfr-dev \ libgmp-dev \ + make \ && rm -rf /var/lib/apt/lists/* - -# Download and build GCC 13.4 -RUN wget https://ftp.gnu.org/gnu/gcc/gcc-13.4.0/gcc-13.4.0.tar.gz && \ - tar -xzf gcc-13.4.0.tar.gz && \ - cd gcc-13.4.0 && \ - ./contrib/download_prerequisites && \ - mkdir build && \ - cd build && \ - ../configure --prefix=/usr/local/gcc-13.4.0 \ - --enable-languages=c,c++ \ - --disable-multilib \ - --disable-bootstrap \ - --enable-checking=release && \ - make -j$(nproc) && \ - make install && \ - cd ../.. && \ - rm -rf gcc-13.4.0 gcc-13.4.0.tar.gz - -# Set up GCC 13.4 as the default compiler -ENV PATH="/usr/local/gcc-13.4.0/bin:${PATH}" -ENV LD_LIBRARY_PATH="/usr/local/gcc-13.4.0/lib64:${LD_LIBRARY_PATH}" -ENV CC="/usr/local/gcc-13.4.0/bin/gcc" -ENV CXX="/usr/local/gcc-13.4.0/bin/g++" - -# Create symlinks for easier access -RUN ln -sf /usr/local/gcc-13.4.0/bin/gcc /usr/local/bin/gcc && \ - ln -sf /usr/local/gcc-13.4.0/bin/g++ /usr/local/bin/g++ - -# Install Make 4.4.1 -RUN wget https://ftp.gnu.org/gnu/make/make-4.4.1.tar.gz && \ - tar -xzf make-4.4.1.tar.gz && \ - cd make-4.4.1 && \ - ./configure --prefix=/usr/local && \ - make -j$(nproc) && \ - make install && \ - cd .. && \ - rm -rf make-4.4.1 make-4.4.1.tar.gz - -# Install CMake 4.0.3 -RUN wget https://github.com/Kitware/CMake/releases/download/v4.0.3/cmake-4.0.3.tar.gz && \ - tar -xzf cmake-4.0.3.tar.gz && \ - cd cmake-4.0.3 && \ - ./bootstrap --prefix=/usr/local && \ - make -j$(nproc) && \ - make install && \ - cd .. && \ - rm -rf cmake-4.0.3 cmake-4.0.3.tar.gz - # Set working directory WORKDIR /usr/src @@ -100,33 +50,32 @@ RUN wget https://www.python.org/ftp/python/${PYTHON_VERSION}/Python-${PYTHON_VER # Ensure /usr/local/bin is in the PATH ENV PATH="/usr/local/bin:${PATH}" -RUN python3 -m pip install --upgrade pip +RUN python3 -m pip install --no-cache-dir --upgrade pip # Use git to clone gtsam and specific GTSAM version -FROM alpine/git:2.52.0 as gtsam-clone +FROM alpine/git:2.52.0 AS gtsam-clone ARG GTSAM_VERSION=4.2.0 WORKDIR /usr/src/ -# Clone GTSAM and checkout to given GTSAM_VERSION tag -RUN git clone --no-checkout https://github.com/borglab/gtsam.git && \ - cd gtsam && \ - git fetch origin tag ${GTSAM_VERSION} && \ - git checkout ${GTSAM_VERSION} +# Shallow clone specific tag for smaller, faster fetch +RUN git clone --depth 1 --branch ${GTSAM_VERSION} https://github.com/borglab/gtsam.git # Create new stage called gtsam for GTSAM building -FROM dependencies as gtsam +FROM dependencies AS gtsam + +ARG PYTHON_VERSION=3.11.2 + +# Needed to link with GTSAM (ENV works in non-interactive shells; .bashrc does not) +ENV LD_LIBRARY_PATH=/usr/local/lib # Move gtsam data COPY --from=gtsam-clone /usr/src/gtsam /usr/src/gtsam WORKDIR /usr/src/gtsam/build -# Needed to link with GTSAM -RUN echo "export LD_LIBRARY_PATH=/usr/local/lib:\$LD_LIBRARY_PATH" >> /root/.bashrc - # Install python wrapper requirements -RUN python3 -m pip install -U -r /usr/src/gtsam/python/requirements.txt +RUN python3 -m pip install --no-cache-dir -U -r /usr/src/gtsam/python/requirements.txt # Run cmake RUN cmake \ @@ -141,14 +90,53 @@ RUN cmake \ -DCMAKE_POLICY_VERSION_MINIMUM=3.5 \ .. -# Make install and clean up +# Build, install, strip binaries, and clean in one layer to reduce image size RUN make -j$(nproc) install && \ make python-install && \ - make clean + find /usr/local -type f \( -name "*.so" -o -name "*.so.*" \) -exec strip --strip-unneeded {} \; 2>/dev/null || true && \ + find /usr/local/bin /usr/local/lib -executable -type f -exec strip --strip-unneeded {} \; 2>/dev/null || true && \ + make clean && \ + ldconfig + +# Final cleanup (dependencies stage already cleared apt lists) +RUN rm -rf /tmp/* /var/tmp/* + +# ----------------------------------------------------------------------------- +# Slim runtime stage: copy only installed artifacts, no build tools or source +# ----------------------------------------------------------------------------- +FROM debian:trixie-slim AS runtime + +ENV DEBIAN_FRONTEND=noninteractive +ENV PATH="/usr/local/bin:${PATH}" +ENV LD_LIBRARY_PATH=/usr/local/lib + +# Runtime libs only (no -dev, no build-essential). Match what Python + GTSAM link to. +# Verify with: ldd /usr/local/lib/libgtsam.so /usr/local/bin/python3.11 (in build image) +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + libssl3t64 \ + libbz2-1.0 \ + libreadline8t64 \ + libsqlite3-0 \ + libffi8 \ + zlib1g \ + libncursesw6 \ + libtbb12 \ + libgmp10 \ + libmpfr6 \ + libmpc3 \ + libboost-serialization1.83.0 \ + libboost-system1.83.0 \ + libboost-thread1.83.0 \ + libboost-date-time1.83.0 \ + libboost-filesystem1.83.0 \ + libboost-chrono1.83.0 \ + libboost-atomic1.83.0 \ + libboost-timer1.83.0 \ + && rm -rf /var/lib/apt/lists/* -RUN apt-get clean && \ - rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* +COPY --from=gtsam /usr/local /usr/local RUN ldconfig -CMD ["bash"] \ No newline at end of file +CMD ["python3"] \ No newline at end of file From c349e766f358b25b3329616da7cf47eca42dc19f Mon Sep 17 00:00:00 2001 From: Warren Snipes Date: Wed, 28 Jan 2026 10:12:18 -0500 Subject: [PATCH 2/3] Update Dockerfile to pin numpy version for GTSAM compatibility and streamline runtime library installation --- Dockerfile | 29 ++++++++--------------------- 1 file changed, 8 insertions(+), 21 deletions(-) diff --git a/Dockerfile b/Dockerfile index 7b2ed46..b7262a6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -74,8 +74,9 @@ COPY --from=gtsam-clone /usr/src/gtsam /usr/src/gtsam WORKDIR /usr/src/gtsam/build -# Install python wrapper requirements -RUN python3 -m pip install --no-cache-dir -U -r /usr/src/gtsam/python/requirements.txt +# Install python wrapper requirements, then pin numpy for GTSAM ABI compatibility +RUN python3 -m pip install --no-cache-dir -U -r /usr/src/gtsam/python/requirements.txt && \ + python3 -m pip install --no-cache-dir "numpy==1.26.4" # Run cmake RUN cmake \ @@ -93,8 +94,8 @@ RUN cmake \ # Build, install, strip binaries, and clean in one layer to reduce image size RUN make -j$(nproc) install && \ make python-install && \ - find /usr/local -type f \( -name "*.so" -o -name "*.so.*" \) -exec strip --strip-unneeded {} \; 2>/dev/null || true && \ - find /usr/local/bin /usr/local/lib -executable -type f -exec strip --strip-unneeded {} \; 2>/dev/null || true && \ + #find /usr/local -type f \( -name "*.so" -o -name "*.so.*" \) -exec strip --strip-unneeded {} \; 2>/dev/null || true && \ + #find /usr/local/bin /usr/local/lib -executable -type f -exec strip --strip-unneeded {} \; 2>/dev/null || true && \ make clean && \ ldconfig @@ -110,28 +111,14 @@ ENV DEBIAN_FRONTEND=noninteractive ENV PATH="/usr/local/bin:${PATH}" ENV LD_LIBRARY_PATH=/usr/local/lib -# Runtime libs only (no -dev, no build-essential). Match what Python + GTSAM link to. -# Verify with: ldd /usr/local/lib/libgtsam.so /usr/local/bin/python3.11 (in build image) +# Runtime libs only. Python binary (ldd python3.11) needs only libc/libm/libpython; GTSAM needs Boost + TBB (see scripts/audit-runtime-deps.sh). +# Add back libssl3t64 libbz2-1.0 libreadline8t64 libsqlite3-0 libffi8 zlib1g libncursesw6 if you import ssl/sqlite3/readline/etc. RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates \ - libssl3t64 \ - libbz2-1.0 \ - libreadline8t64 \ - libsqlite3-0 \ - libffi8 \ - zlib1g \ - libncursesw6 \ libtbb12 \ - libgmp10 \ - libmpfr6 \ - libmpc3 \ + libtbbmalloc2 \ libboost-serialization1.83.0 \ - libboost-system1.83.0 \ - libboost-thread1.83.0 \ - libboost-date-time1.83.0 \ libboost-filesystem1.83.0 \ - libboost-chrono1.83.0 \ - libboost-atomic1.83.0 \ libboost-timer1.83.0 \ && rm -rf /var/lib/apt/lists/* From 77f41fc55901b34c35531c2f230f17504ab76781 Mon Sep 17 00:00:00 2001 From: Warren Snipes Date: Wed, 28 Jan 2026 10:12:41 -0500 Subject: [PATCH 3/3] Add GitHub Actions workflows for building and validating GTSAM Docker images - Introduced a new workflow for building and validating Docker images on multiple platforms (linux/amd64 and linux/arm64). - Updated the release workflow to include validation steps for the Docker images. - Added example scripts for validating GTSAM functionality within the container. - Created a shell script to facilitate running validation examples inside the Docker container. --- .github/workflows/build-and-validate.yaml | 33 ++++++++ .github/workflows/release.yaml | 43 +++++++++-- examples/PlanarSLAMExample.py | 93 +++++++++++++++++++++++ examples/validate_gtsam.py | 29 +++++++ scripts/validate_container.sh | 55 ++++++++++++++ 5 files changed, 246 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/build-and-validate.yaml create mode 100644 examples/PlanarSLAMExample.py create mode 100644 examples/validate_gtsam.py create mode 100755 scripts/validate_container.sh diff --git a/.github/workflows/build-and-validate.yaml b/.github/workflows/build-and-validate.yaml new file mode 100644 index 0000000..1d994dc --- /dev/null +++ b/.github/workflows/build-and-validate.yaml @@ -0,0 +1,33 @@ +name: Build and Validate + +on: + pull_request: + branches: [main, development] + +jobs: + build-and-validate: + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-latest + - platform: linux/arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build image (${{ matrix.platform }}) + run: docker build --platform ${{ matrix.platform }} -t gtsam_docker:latest . + + - name: Validate image (PlanarSLAM example) + run: | + chmod +x ./scripts/validate_container.sh + ./scripts/validate_container.sh gtsam_docker:latest /examples/PlanarSLAMExample.py diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 19d1e97..3e677c1 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -21,8 +21,8 @@ jobs: build: strategy: matrix: - platform: [ "linux/amd64" ] - # Use GitHub-hosted runner for amd64 and the arm64 partner runner for arm64 + platform: [ "linux/amd64", "linux/arm64" ] + # Use GitHub-hosted runner for amd64; arm64 uses partner runner (ensure ubuntu-24.04-arm is enabled for the repo/org) runs-on: ${{ matrix.platform == 'linux/arm64' && 'ubuntu-24.04-arm' || 'ubuntu-latest' }} permissions: contents: read @@ -62,8 +62,37 @@ jobs: tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - manifest: + validate: needs: build + strategy: + fail-fast: false + matrix: + arch: [ amd64, arm64 ] + runs-on: ${{ matrix.arch == 'arm64' && 'ubuntu-24.04-arm' || 'ubuntu-latest' }} + permissions: + contents: read + packages: read + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Log into registry ${{ env.REGISTRY }} + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Pull and validate image (${{ matrix.arch }}) + run: | + LOWER_IMAGE_NAME=$(echo "${{ env.IMAGE_NAME }}" | tr '[:upper:]' '[:lower:]') + docker pull ${{ env.REGISTRY }}/${LOWER_IMAGE_NAME}:${{ inputs.version }}-${{ matrix.arch }} + docker tag ${{ env.REGISTRY }}/${LOWER_IMAGE_NAME}:${{ inputs.version }}-${{ matrix.arch }} gtsam_docker:latest + chmod +x ./scripts/validate_container.sh + ./scripts/validate_container.sh gtsam_docker:latest /examples/PlanarSLAMExample.py + + manifest: + needs: validate runs-on: ubuntu-latest permissions: contents: read @@ -78,18 +107,18 @@ jobs: - name: Create multi-arch manifest for latest run: | - # Convert IMAGE_NAME to lowercase LOWER_IMAGE_NAME=$(echo "${IMAGE_NAME}" | tr '[:upper:]' '[:lower:]') docker manifest create $REGISTRY/${LOWER_IMAGE_NAME}:latest \ - $REGISTRY/${LOWER_IMAGE_NAME}:latest-amd64 + $REGISTRY/${LOWER_IMAGE_NAME}:latest-amd64 \ + $REGISTRY/${LOWER_IMAGE_NAME}:latest-arm64 docker manifest push $REGISTRY/${LOWER_IMAGE_NAME}:latest - name: Create multi-arch manifest for version tag run: | - # Convert IMAGE_NAME to lowercase LOWER_IMAGE_NAME=$(echo "${IMAGE_NAME}" | tr '[:upper:]' '[:lower:]') docker manifest create $REGISTRY/${LOWER_IMAGE_NAME}:${{ inputs.version }} \ - $REGISTRY/${LOWER_IMAGE_NAME}:${{ inputs.version }}-amd64 + $REGISTRY/${LOWER_IMAGE_NAME}:${{ inputs.version }}-amd64 \ + $REGISTRY/${LOWER_IMAGE_NAME}:${{ inputs.version }}-arm64 docker manifest push $REGISTRY/${LOWER_IMAGE_NAME}:${{ inputs.version }} release: diff --git a/examples/PlanarSLAMExample.py b/examples/PlanarSLAMExample.py new file mode 100644 index 0000000..4dd1610 --- /dev/null +++ b/examples/PlanarSLAMExample.py @@ -0,0 +1,93 @@ +""" +GTSAM Copyright 2010-2018, Georgia Tech Research Corporation, +Atlanta, Georgia 30332-0415 +All Rights Reserved +Authors: Frank Dellaert, et al. (see THANKS for the full author list) +See LICENSE for the license information + +Simple robotics example using odometry measurements and bearing-range (laser) measurements. +From borglab/gtsam python/gtsam/examples/PlanarSLAMExample.py (GTSAM 4.2.0). + +Run inside container: python3 /examples/PlanarSLAMExample.py +""" +# pylint: disable=invalid-name, E1101 + +from __future__ import print_function + +import sys +import gtsam +import numpy as np +from gtsam.symbol_shorthand import L, X + +# Create noise models +PRIOR_NOISE = gtsam.noiseModel.Diagonal.Sigmas(np.array([0.3, 0.3, 0.1])) +ODOMETRY_NOISE = gtsam.noiseModel.Diagonal.Sigmas(np.array([0.2, 0.2, 0.1])) +MEASUREMENT_NOISE = gtsam.noiseModel.Diagonal.Sigmas(np.array([0.1, 0.2])) + + +def main(): + """Main runner.""" + # Create an empty nonlinear factor graph + graph = gtsam.NonlinearFactorGraph() + + # Create the keys corresponding to unknown variables in the factor graph + x1, x2, x3 = X(1), X(2), X(3) + l1, l2 = L(4), L(5) + + # Add a prior on pose X1 at the origin + graph.add( + gtsam.PriorFactorPose2(x1, gtsam.Pose2(0.0, 0.0, 0.0), PRIOR_NOISE) + ) + + # Add odometry factors between X1,X2 and X2,X3 + graph.add( + gtsam.BetweenFactorPose2(x1, x2, gtsam.Pose2(2.0, 0.0, 0.0), ODOMETRY_NOISE) + ) + graph.add( + gtsam.BetweenFactorPose2(x2, x3, gtsam.Pose2(2.0, 0.0, 0.0), ODOMETRY_NOISE) + ) + + # Add Range-Bearing measurements to two different landmarks L1 and L2 + graph.add( + gtsam.BearingRangeFactor2D( + x1, l1, gtsam.Rot2.fromDegrees(45), np.sqrt(4.0 + 4.0), MEASUREMENT_NOISE + ) + ) + graph.add( + gtsam.BearingRangeFactor2D(x2, l1, gtsam.Rot2.fromDegrees(90), 2.0, MEASUREMENT_NOISE) + ) + graph.add( + gtsam.BearingRangeFactor2D(x3, l2, gtsam.Rot2.fromDegrees(90), 2.0, MEASUREMENT_NOISE) + ) + + print("Factor Graph:\n{}".format(graph)) + + # Create (deliberately inaccurate) initial estimate + initial_estimate = gtsam.Values() + initial_estimate.insert(x1, gtsam.Pose2(-0.25, 0.20, 0.15)) + initial_estimate.insert(x2, gtsam.Pose2(2.30, 0.10, -0.20)) + initial_estimate.insert(x3, gtsam.Pose2(4.10, 0.10, 0.10)) + initial_estimate.insert(l1, gtsam.Point2(1.80, 2.10)) + initial_estimate.insert(l2, gtsam.Point2(4.10, 1.80)) + + print("Initial Estimate:\n{}".format(initial_estimate)) + + # Optimize using Levenberg-Marquardt + params = gtsam.LevenbergMarquardtParams() + optimizer = gtsam.LevenbergMarquardtOptimizer(graph, initial_estimate, params) + result = optimizer.optimize() + print("\nFinal Result:\n{}".format(result)) + + # Calculate and print marginal covariances + marginals = gtsam.Marginals(graph, result) + for (key, label) in [(x1, "X1"), (x2, "X2"), (x3, "X3"), (l1, "L1"), (l2, "L2")]: + print("{} covariance:\n{}\n".format(label, marginals.marginalCovariance(key))) + + # Validation: expect result size and non-NaN covariances + assert result.size() == 5, "Expected 5 values in result" + _ = marginals.marginalCovariance(x1) # will raise if invalid + print("VALIDATION OK") + return 0 + +if __name__ == "__main__": + sys.exit(main()) diff --git a/examples/validate_gtsam.py b/examples/validate_gtsam.py new file mode 100644 index 0000000..7935937 --- /dev/null +++ b/examples/validate_gtsam.py @@ -0,0 +1,29 @@ +""" +Minimal GTSAM sanity check for container validation. +Uses symbol_shorthand, Pose2, and Values (same API as PlanarSLAM/Odometry examples). +Avoids noiseModel.Diagonal.Sigmas(numpy_array), which can segfault when numpy +ABI doesn't match the version GTSAM was built against. + +Run: python3 /examples/validate_gtsam.py +""" +from __future__ import print_function + +import sys +import gtsam +from gtsam.symbol_shorthand import X + +def main(): + # Core types from the official examples, no numpy + x1 = X(1) + values = gtsam.Values() + values.insert(x1, gtsam.Pose2(0.0, 0.0, 0.0)) + assert values.size() == 1 + pose = values.atPose2(x1) + assert pose.x() == 0.0 and pose.y() == 0.0 + graph = gtsam.NonlinearFactorGraph() + assert graph.size() == 0 + print("VALIDATION OK") + return 0 + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/validate_container.sh b/scripts/validate_container.sh new file mode 100755 index 0000000..17fbee4 --- /dev/null +++ b/scripts/validate_container.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# Run a GTSAM example inside the runtime container to validate the image. +# +# Usage: +# ./scripts/validate_container.sh [IMAGE_TAG] [EXAMPLE] +# +# Examples: +# ./scripts/validate_container.sh +# ./scripts/validate_container.sh gtsam_docker:latest +# ./scripts/validate_container.sh gtsam_docker:latest /examples/PlanarSLAMExample.py +# +# Default EXAMPLE is /examples/validate_gtsam.py (minimal graph/values check). +# Use /examples/PlanarSLAMExample.py for the full PlanarSLAM example from borglab/gtsam. +# +# Prereq: build the runtime image first, e.g. docker build -t gtsam_docker:latest . + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +EXAMPLES_DIR="$REPO_ROOT/examples" +IMAGE="${1:-gtsam_docker:latest}" +EXAMPLE="${2:-/examples/validate_gtsam.py}" +# If EXAMPLE has no leading slash, treat as name under /examples/ +if [[ -n "$EXAMPLE" && "$EXAMPLE" != /* ]]; then + EXAMPLE="/examples/$EXAMPLE" +fi + +if [[ ! -d "$EXAMPLES_DIR" ]]; then + echo "ERROR: Examples directory not found: $EXAMPLES_DIR" >&2 + exit 1 +fi + +if ! docker image inspect "$IMAGE" >/dev/null 2>&1; then + echo "Image $IMAGE not found. Build it first, e.g.:" >&2 + echo " docker build -t gtsam_docker:latest ." >&2 + exit 1 +fi + +echo "Running GTSAM example in container (image: $IMAGE, script: $EXAMPLE)..." +echo "---" + +docker run --rm \ + -v "$EXAMPLES_DIR:/examples:ro" \ + "$IMAGE" \ + python3 "$EXAMPLE" + +EXIT_CODE=$? +echo "---" +if [[ $EXIT_CODE -eq 0 ]]; then + echo "OK: Example finished with exit code 0." +else + echo "FAIL: Example exited with code $EXIT_CODE." >&2 + exit $EXIT_CODE +fi