CI: add pre-configured smoke test container images to speed up pipelines

libeigen/eigen!2515

diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 27385a1..8153bfd 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -32,6 +32,14 @@
   - deploy
 
 variables:
+  # Pre-baked CI images hosted in the GitLab container registry.
+  # Override in project/fork settings if you host the images elsewhere.
+  # See ci/docker/ for the corresponding Dockerfiles.
+  # Slim images used for MR smoke tests (gcc-10 + clang-14).
+  # Builds are cross-compiled on arm64; tests run on their native runner.
+  EIGEN_CI_IMAGE_LINUX_AMD64_SMOKETEST_RUN: "${CI_REGISTRY_IMAGE}/ubuntu-24.04-amd64-smoketest-run:latest"
+  EIGEN_CI_IMAGE_LINUX_ARM64_SMOKETEST_BUILD: "${CI_REGISTRY_IMAGE}/ubuntu-24.04-arm64-smoketest-build:latest"
+  EIGEN_CI_IMAGE_LINUX_ARM64_SMOKETEST_RUN: "${CI_REGISTRY_IMAGE}/ubuntu-24.04-arm64-smoketest-run:latest"
   # CMake build directory.
   EIGEN_CI_BUILDDIR: .build
   # Specify the CMake build target.
@@ -45,6 +53,7 @@
 include:
   - "/ci/checkformat.gitlab-ci.yml"
   - "/ci/common.gitlab-ci.yml"
+  - "/ci/images.gitlab-ci.yml"
   - "/ci/build.linux.gitlab-ci.yml"
   - "/ci/build.windows.gitlab-ci.yml"
   - "/ci/test.linux.gitlab-ci.yml"
diff --git a/ci/build.linux.gitlab-ci.yml b/ci/build.linux.gitlab-ci.yml
index 1788bae..cdea575 100644
--- a/ci/build.linux.gitlab-ci.yml
+++ b/ci/build.linux.gitlab-ci.yml
@@ -7,6 +7,7 @@
   stage: build
   variables:
     EIGEN_CI_BUILD_TARGET: buildtests
+    EIGEN_CI_SKIP_APT: "false"
   script:
     - . ci/scripts/build.linux.script.sh
   tags:
@@ -439,28 +440,48 @@
       -DEIGEN_TEST_LSX=on
 
 ######## MR Smoke Tests ########################################################
+# All builds run on arm64 runners using the slim arm64 smoketest image.
+# x86-64 targets are cross-compiled via g++-10-x86-64-linux-gnu / clang-14.
+# Tests run on their respective native runners (see test.linux.gitlab-ci.yml).
 
 build:linux:cross:x86-64:gcc-10:default:smoketest:
   extends: build:linux:cross:x86-64:gcc-10:default
+  image: ${EIGEN_CI_IMAGE_LINUX_ARM64_SMOKETEST_BUILD}
   variables:
+    EIGEN_CI_SKIP_APT: "true"
     EIGEN_CI_BUILD_TARGET: buildsmoketests
   rules:
     - if: $CI_PIPELINE_SOURCE == "merge_request_event"
   tags:
-    - saas-linux-medium-amd64
+    - saas-linux-medium-arm64
 
 build:linux:cross:x86-64:clang-14:default:smoketest:
   extends: build:linux:cross:x86-64:clang-14:default
+  image: ${EIGEN_CI_IMAGE_LINUX_ARM64_SMOKETEST_BUILD}
   variables:
+    EIGEN_CI_SKIP_APT: "true"
     EIGEN_CI_BUILD_TARGET: buildsmoketests
   rules:
     - if: $CI_PIPELINE_SOURCE == "merge_request_event"
   tags:
-    - saas-linux-medium-amd64
+    - saas-linux-medium-arm64
 
 build:linux:aarch64:gcc-10:default:smoketest:
   extends: build:linux:cross:aarch64:gcc-10:default
+  image: ${EIGEN_CI_IMAGE_LINUX_ARM64_SMOKETEST_BUILD}
   variables:
+    EIGEN_CI_SKIP_APT: "true"
+    EIGEN_CI_BUILD_TARGET: buildsmoketests
+  rules:
+    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
+  tags:
+    - saas-linux-medium-arm64
+
+build:linux:aarch64:clang-14:default:smoketest:
+  extends: build:linux:cross:aarch64:clang-14:default
+  image: ${EIGEN_CI_IMAGE_LINUX_ARM64_SMOKETEST_BUILD}
+  variables:
+    EIGEN_CI_SKIP_APT: "true"
     EIGEN_CI_BUILD_TARGET: buildsmoketests
   rules:
     - if: $CI_PIPELINE_SOURCE == "merge_request_event"
diff --git a/ci/docker/ubuntu-24.04-amd64-smoketest-run/Dockerfile b/ci/docker/ubuntu-24.04-amd64-smoketest-run/Dockerfile
new file mode 100644
index 0000000..50c8595
--- /dev/null
+++ b/ci/docker/ubuntu-24.04-amd64-smoketest-run/Dockerfile
@@ -0,0 +1,29 @@
+# SPDX-FileCopyrightText: The Eigen Authors
+# SPDX-License-Identifier: MPL-2.0
+#
+# Minimal CI image for running x86-64 MR smoke tests (Ubuntu 24.04).
+# Test-only: no compilers. Binaries are cross-compiled on arm64 runners
+# using ubuntu-24.04-arm64-smoketest-build and transferred as artifacts.
+#
+# Packages:
+#   cmake     - provides ctest
+#   xsltproc  - converts CTest XML to JUnit in test.linux.after_script.sh
+#   libgomp1  - OpenMP runtime for test binaries (libstdc++6/libgcc-s1
+#               are already present in the Ubuntu 24.04 base image)
+#
+# Rebuild and push when this file changes:
+#   docker buildx build --platform linux/amd64 \
+#     -t registry.gitlab.com/libeigen/eigen/ubuntu-24.04-amd64-smoketest-run:latest \
+#     --push ci/docker/ubuntu-24.04-amd64-smoketest-run/
+
+FROM ubuntu:24.04
+
+ARG DEBIAN_FRONTEND=noninteractive
+
+RUN apt-get update -y && \
+    apt-get install -y --no-install-recommends \
+      cmake \
+      xsltproc \
+      libgomp1 \
+    && apt-get clean \
+    && rm -rf /var/lib/apt/lists/*
diff --git a/ci/docker/ubuntu-24.04-arm64-smoketest-build/Dockerfile b/ci/docker/ubuntu-24.04-arm64-smoketest-build/Dockerfile
new file mode 100644
index 0000000..04e112e
--- /dev/null
+++ b/ci/docker/ubuntu-24.04-arm64-smoketest-build/Dockerfile
@@ -0,0 +1,24 @@
+# SPDX-FileCopyrightText: The Eigen Authors
+# SPDX-License-Identifier: MPL-2.0
+#
+# Slim CI image for arm64 MR smoke test jobs (Ubuntu 24.04).
+# Builds all four smoke test configurations from arm64 runners:
+#   gcc-10 and clang-14 for aarch64 (native), and for x86-64 (cross-compiled).
+#
+# Rebuild and push when this file changes:
+#   docker buildx build --platform linux/arm64 \
+#     -t registry.gitlab.com/libeigen/eigen/ubuntu-24.04-arm64-smoketest-build:latest \
+#     --push ci/docker/ubuntu-24.04-arm64-smoketest-build/
+
+FROM ubuntu:24.04
+
+ARG DEBIAN_FRONTEND=noninteractive
+
+RUN apt-get update -y && \
+    apt-get install -y --no-install-recommends \
+      cmake ninja-build git \
+      gcc-10 g++-10 \
+      g++-10-x86-64-linux-gnu \
+      clang-14 \
+    && apt-get clean \
+    && rm -rf /var/lib/apt/lists/*
diff --git a/ci/docker/ubuntu-24.04-arm64-smoketest-run/Dockerfile b/ci/docker/ubuntu-24.04-arm64-smoketest-run/Dockerfile
new file mode 100644
index 0000000..f453185
--- /dev/null
+++ b/ci/docker/ubuntu-24.04-arm64-smoketest-run/Dockerfile
@@ -0,0 +1,29 @@
+# SPDX-FileCopyrightText: The Eigen Authors
+# SPDX-License-Identifier: MPL-2.0
+#
+# Minimal CI image for running aarch64 MR smoke tests (Ubuntu 24.04).
+# Test-only: no compilers. Binaries are built natively on arm64 runners
+# using ubuntu-24.04-arm64-smoketest-build and transferred as artifacts.
+#
+# Packages:
+#   cmake     - provides ctest
+#   xsltproc  - converts CTest XML to JUnit in test.linux.after_script.sh
+#   libgomp1  - OpenMP runtime for test binaries (libstdc++6/libgcc-s1
+#               are already present in the Ubuntu 24.04 base image)
+#
+# Rebuild and push when this file changes:
+#   docker buildx build --platform linux/arm64 \
+#     -t registry.gitlab.com/libeigen/eigen/ubuntu-24.04-arm64-smoketest-run:latest \
+#     --push ci/docker/ubuntu-24.04-arm64-smoketest-run/
+
+FROM ubuntu:24.04
+
+ARG DEBIAN_FRONTEND=noninteractive
+
+RUN apt-get update -y && \
+    apt-get install -y --no-install-recommends \
+      cmake \
+      xsltproc \
+      libgomp1 \
+    && apt-get clean \
+    && rm -rf /var/lib/apt/lists/*
diff --git a/ci/images.gitlab-ci.yml b/ci/images.gitlab-ci.yml
new file mode 100644
index 0000000..dcdc531
--- /dev/null
+++ b/ci/images.gitlab-ci.yml
@@ -0,0 +1,76 @@
+# SPDX-FileCopyrightText: The Eigen Authors
+# SPDX-License-Identifier: MPL-2.0
+#
+# Builds and pushes CI container images to the GitLab registry.
+#
+# Automatic: triggered on push to the default branch when a Dockerfile changes.
+# Manual:    available as a manual job in any pipeline for routine rebuilds
+#            (e.g. to pick up upstream package updates without touching the file).
+
+.build:docker:
+  stage: deploy
+  image: docker:latest
+  services:
+    - docker:dind
+  variables:
+    DOCKER_TLS_CERTDIR: "/certs"
+  # needs: [] lets these jobs run without waiting for build/test stages,
+  # since the new image is only consumed by future pipelines anyway.
+  needs: []
+  before_script:
+    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
+
+.build:docker:linux:amd64:
+  extends: .build:docker
+  tags:
+    - saas-linux-small-amd64
+
+.build:docker:linux:arm64:
+  extends: .build:docker
+  tags:
+    - saas-linux-small-arm64
+
+build:docker:ubuntu-24.04-amd64-smoketest-run:
+  extends: .build:docker:linux:amd64
+  script:
+    - docker buildx build --platform linux/amd64
+        -t $EIGEN_CI_IMAGE_LINUX_AMD64_SMOKETEST_RUN
+        --push
+        ci/docker/ubuntu-24.04-amd64-smoketest-run/
+  rules:
+    - if: $CI_PIPELINE_SOURCE == "push" && $CI_PROJECT_NAMESPACE == "libeigen" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
+      changes:
+        - ci/docker/ubuntu-24.04-amd64-smoketest-run/Dockerfile
+    - if: $CI_PROJECT_NAMESPACE == "libeigen"
+      when: manual
+      allow_failure: true
+
+build:docker:ubuntu-24.04-arm64-smoketest-build:
+  extends: .build:docker:linux:arm64
+  script:
+    - docker buildx build --platform linux/arm64
+        -t $EIGEN_CI_IMAGE_LINUX_ARM64_SMOKETEST_BUILD
+        --push
+        ci/docker/ubuntu-24.04-arm64-smoketest-build/
+  rules:
+    - if: $CI_PIPELINE_SOURCE == "push" && $CI_PROJECT_NAMESPACE == "libeigen" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
+      changes:
+        - ci/docker/ubuntu-24.04-arm64-smoketest-build/Dockerfile
+    - if: $CI_PROJECT_NAMESPACE == "libeigen"
+      when: manual
+      allow_failure: true
+
+build:docker:ubuntu-24.04-arm64-smoketest-run:
+  extends: .build:docker:linux:arm64
+  script:
+    - docker buildx build --platform linux/arm64
+        -t $EIGEN_CI_IMAGE_LINUX_ARM64_SMOKETEST_RUN
+        --push
+        ci/docker/ubuntu-24.04-arm64-smoketest-run/
+  rules:
+    - if: $CI_PIPELINE_SOURCE == "push" && $CI_PROJECT_NAMESPACE == "libeigen" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
+      changes:
+        - ci/docker/ubuntu-24.04-arm64-smoketest-run/Dockerfile
+    - if: $CI_PROJECT_NAMESPACE == "libeigen"
+      when: manual
+      allow_failure: true
diff --git a/ci/scripts/common.linux.before_script.sh b/ci/scripts/common.linux.before_script.sh
index fd655d4..1303e14 100755
--- a/ci/scripts/common.linux.before_script.sh
+++ b/ci/scripts/common.linux.before_script.sh
@@ -17,18 +17,24 @@
 # Set noninteractive, otherwise tzdata may be installed and prompt for a
 # geographical region.
 export DEBIAN_FRONTEND=noninteractive
-apt-get update -y > /dev/null
-apt-get install -y --no-install-recommends ninja-build cmake git xsltproc > /dev/null
+if [[ "${EIGEN_CI_SKIP_APT}" != "true" ]]; then
+  apt-get update -y > /dev/null
+  apt-get install -y --no-install-recommends ninja-build cmake git xsltproc > /dev/null
+fi
 
 # Install required dependencies and set up compilers.
 # These are required even for testing to ensure that dynamic runtime libraries
 # are available.
 if [[ "$ARCH" == "${EIGEN_CI_TARGET_ARCH}" || "${EIGEN_CI_TARGET_ARCH}" == "any" ]]; then
-  apt-get install -y --no-install-recommends ${EIGEN_CI_INSTALL} > /dev/null;
+  if [[ "${EIGEN_CI_SKIP_APT}" != "true" ]]; then
+    apt-get install -y --no-install-recommends ${EIGEN_CI_INSTALL} > /dev/null;
+  fi
   export EIGEN_CI_CXX_IMPLICIT_INCLUDE_DIRECTORIES="";
   export EIGEN_CI_CXX_COMPILER_TARGET="";
 else
-  apt-get install -y --no-install-recommends ${EIGEN_CI_CROSS_INSTALL} > /dev/null;
+  if [[ "${EIGEN_CI_SKIP_APT}" != "true" ]]; then
+    apt-get install -y --no-install-recommends ${EIGEN_CI_CROSS_INSTALL} > /dev/null;
+  fi
   export EIGEN_CI_C_COMPILER=${EIGEN_CI_CROSS_C_COMPILER};
   export EIGEN_CI_CXX_COMPILER=${EIGEN_CI_CROSS_CXX_COMPILER};
   export EIGEN_CI_CXX_COMPILER_TARGET=${EIGEN_CI_CROSS_TARGET_TRIPLE};
diff --git a/ci/test.linux.gitlab-ci.yml b/ci/test.linux.gitlab-ci.yml
index 7ede11b..6a45706 100644
--- a/ci/test.linux.gitlab-ci.yml
+++ b/ci/test.linux.gitlab-ci.yml
@@ -497,8 +497,10 @@
 
 test:linux:x86-64:gcc-10:default:smoketest:
   extends: .test:linux:x86-64:gcc-10:default
+  image: ${EIGEN_CI_IMAGE_LINUX_AMD64_SMOKETEST_RUN}
   needs: [ build:linux:cross:x86-64:gcc-10:default:smoketest ]
   variables:
+    EIGEN_CI_SKIP_APT: "true"
     EIGEN_CI_CTEST_LABEL: smoketest
   rules:
     - if: $CI_PIPELINE_SOURCE == "merge_request_event"
@@ -507,8 +509,10 @@
 
 test:linux:x86-64:clang-14:default:smoketest:
   extends: .test:linux:x86-64:clang-14:default
+  image: ${EIGEN_CI_IMAGE_LINUX_AMD64_SMOKETEST_RUN}
   needs: [ build:linux:cross:x86-64:clang-14:default:smoketest ]
   variables:
+    EIGEN_CI_SKIP_APT: "true"
     EIGEN_CI_CTEST_LABEL: smoketest
   rules:
     - if: $CI_PIPELINE_SOURCE == "merge_request_event"
@@ -517,8 +521,22 @@
 
 test:linux:aarch64:gcc-10:default:smoketest:
   extends: .test:linux:aarch64:gcc-10:default
+  image: ${EIGEN_CI_IMAGE_LINUX_ARM64_SMOKETEST_RUN}
   needs: [ build:linux:aarch64:gcc-10:default:smoketest ]
   variables:
+    EIGEN_CI_SKIP_APT: "true"
+    EIGEN_CI_CTEST_LABEL: smoketest
+  rules:
+    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
+  tags:
+    - saas-linux-medium-arm64
+
+test:linux:aarch64:clang-14:default:smoketest:
+  extends: .test:linux:aarch64:clang-14:default
+  image: ${EIGEN_CI_IMAGE_LINUX_ARM64_SMOKETEST_RUN}
+  needs: [ build:linux:aarch64:clang-14:default:smoketest ]
+  variables:
+    EIGEN_CI_SKIP_APT: "true"
     EIGEN_CI_CTEST_LABEL: smoketest
   rules:
     - if: $CI_PIPELINE_SOURCE == "merge_request_event"