From 8ea31e43171b6f109c07aa9b3d51c1fd4a2e2b86 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sun, 3 May 2026 11:40:50 -0500 Subject: [PATCH] ci: add prop-base image build pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-builds the runtime base (wgrib2 + g2c + cdo + gdal-bin + locale + runtime apt deps) into git.mcintire.me/graham/prop-base. The base workflow is path-filtered to fire only on Dockerfile.base / build-base.yaml changes, so version bumps stay deterministic and explicit. Tag convention: `wgrib2--g2c--` for traceability + rollback, plus `:latest` for everyday consumption. This commit only adds the new files — the app Dockerfile + build.yaml still build wgrib2 from source. A follow-up will swap the app Dockerfile to FROM prop-base:latest once this image lands in the registry. --- .forgejo/workflows/build-base.yaml | 129 +++++++++++++++++++++++++++++ Dockerfile.base | 100 ++++++++++++++++++++++ 2 files changed, 229 insertions(+) create mode 100644 .forgejo/workflows/build-base.yaml create mode 100644 Dockerfile.base diff --git a/.forgejo/workflows/build-base.yaml b/.forgejo/workflows/build-base.yaml new file mode 100644 index 00000000..cf46a601 --- /dev/null +++ b/.forgejo/workflows/build-base.yaml @@ -0,0 +1,129 @@ +name: Build base image + +# Path-filtered so this only fires when the base Dockerfile (or this +# workflow) changes — typically a wgrib2 / g2c version bump or apt-dep +# tweak. `workflow_dispatch` lets us rebuild on demand without touching +# the file (e.g. to refresh CVE patches in the apt layer). +on: + push: + branches: + - main + paths: + - 'Dockerfile.base' + - '.forgejo/workflows/build-base.yaml' + workflow_dispatch: + +env: + REGISTRY: git.mcintire.me + IMAGE_NAME: graham/prop-base + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-and-push: + name: Build and push base image + runs-on: ubuntu-22.04 + + steps: + - name: Checkout code + uses: https://github.com/actions/checkout@v4 + + # Static docker + buildx download — same dance as build.yaml / + # build-grid-rs.yaml. The runner image's bookworm apt repos have + # broken GPG signatures, so we bypass apt entirely. + - name: Install Docker CLI and buildx + run: | + curl -fsSL -o /tmp/docker.tgz \ + https://download.docker.com/linux/static/stable/x86_64/docker-27.5.1.tgz + tar xzf /tmp/docker.tgz -C /tmp + install -m 0755 /tmp/docker/docker /usr/local/bin/docker + rm -rf /tmp/docker /tmp/docker.tgz + + mkdir -p /usr/libexec/docker/cli-plugins + curl -fsSL -o /usr/libexec/docker/cli-plugins/docker-buildx \ + https://github.com/docker/buildx/releases/download/v0.19.3/buildx-v0.19.3.linux-amd64 + chmod +x /usr/libexec/docker/cli-plugins/docker-buildx + + docker --version + docker buildx version + + - name: Wait for Docker daemon + run: | + sock="${DOCKER_HOST:-unix:///var/run/docker.sock}" + echo "Using DOCKER_HOST=${sock}" + + for i in $(seq 1 30); do + if docker info >/dev/null 2>&1; then + echo "Docker daemon reachable after ${i}s" + exit 0 + fi + echo "Waiting for Docker daemon... ($i/30)" + sleep 1 + done + + echo "Docker daemon never became reachable — diagnostics:" >&2 + ls -l /var/run/docker.sock 2>&1 || true + env | grep -i docker 2>&1 || true + docker info 2>&1 || true + exit 1 + + - name: Generate image tag + id: tag + run: | + # Embed the wgrib2 + g2c versions in the tag so prior bases + # remain pullable for hotfix rollbacks if a version bump + # regresses something at runtime. + WGRIB2=$(grep -E '^ARG WGRIB2_VERSION=' Dockerfile.base | head -1 | cut -d= -f2) + G2C=$(grep -E '^ARG G2C_VERSION=' Dockerfile.base | head -1 | cut -d= -f2) + TIMESTAMP=$(date +%s) + TAG="wgrib2-${WGRIB2}-g2c-${G2C}-${TIMESTAMP}" + echo "tag=${TAG}" >> $GITHUB_OUTPUT + + - name: Log in to container registry + run: | + attempt=1 + max_attempts=3 + while : ; do + if echo "${{ secrets.REGISTRY_PASSWORD }}" | \ + docker login "${{ secrets.REGISTRY_URL }}" \ + -u "${{ secrets.REGISTRY_USER }}" \ + --password-stdin; then + exit 0 + fi + if [ "$attempt" -ge "$max_attempts" ]; then + echo "docker login failed after $attempt attempts" >&2 + exit 1 + fi + delay=$((2 ** attempt)) + echo "docker login attempt $attempt failed; retrying in ${delay}s" + sleep "$delay" + attempt=$((attempt + 1)) + done + + - name: Build and push base image + run: | + IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}" + TAG="${{ steps.tag.outputs.tag }}" + + attempt=1 + max_attempts=3 + while : ; do + if docker buildx build \ + -f Dockerfile.base \ + -t "${IMAGE}:${TAG}" \ + -t "${IMAGE}:latest" \ + --push \ + .; then + exit 0 + fi + if [ "$attempt" -ge "$max_attempts" ]; then + echo "docker buildx build failed after $attempt attempts" >&2 + exit 1 + fi + delay=$((2 ** attempt)) + echo "docker buildx build attempt $attempt failed; retrying in ${delay}s" + sleep "$delay" + attempt=$((attempt + 1)) + done diff --git a/Dockerfile.base b/Dockerfile.base new file mode 100644 index 00000000..d033fcb7 --- /dev/null +++ b/Dockerfile.base @@ -0,0 +1,100 @@ +# syntax=docker/dockerfile:1.6 +# +# Pre-built runtime base image for the Elixir app. Contains: +# * Debian trixie-slim with locale set to en_US.UTF-8 +# * Runtime apt deps for the BEAM + Erlang ssl + snmp +# * `cdo` + `gdal-bin` (~1 GB dep tree, the slowest install) +# * `wgrib2` (compiled here from source against NCEPLIBS-g2c v2.3.0) +# +# Built by `.forgejo/workflows/build-base.yaml` only when this file or +# the workflow changes. The app `Dockerfile` does `FROM prop-base` so +# everyday code pushes skip the wgrib2 compile and apt-install entirely. +# +# Bumping wgrib2 or g2c: change the ARGs below and push — the base +# workflow rebuilds and re-tags `:latest`. The app Dockerfile follows +# `:latest`, so the next app push picks it up automatically. + +ARG DEBIAN_VERSION=trixie-20260316-slim +ARG RUNNER_IMAGE="docker.io/debian:${DEBIAN_VERSION}" + +# ---- wgrib2 build stage (cached independently inside this image) ---- +FROM ${RUNNER_IMAGE} AS wgrib2-builder + +ARG WGRIB2_VERSION=3.8.0 +# wgrib2 3.6.0 has a memory-corruption bug exposed by HRDPS rotated +# lat/lon GRIB2 files plus -lon point extraction (denormal garbage +# values, then `free(): invalid size`). Reproduced in the production +# pod against an HRDPS sample on 2026-04-30; 3.8.0 fixes it. +# g2c v2.3.0 is the minimum wgrib2 3.8.0's CMakeLists demands via +# find_package, and the first release where BUILD_G2C defaults ON. +ARG G2C_VERSION=2.3.0 + +RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,target=/var/lib/apt,sharing=locked \ + rm -f /etc/apt/apt.conf.d/docker-clean \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + build-essential gfortran cmake wget ca-certificates pkg-config \ + zlib1g-dev libaec-dev libpng-dev libopenjp2-7-dev + +# NCEPLIBS-g2c carries the actual JPEG2000 / PNG / AEC GRIB2 decoders +# that wgrib2 dispatches to via USE_G2CLIB_LOW. Jasper is disabled +# (CVE-laden, dropped from Debian). +WORKDIR /tmp/g2c +RUN wget -q --content-disposition "https://github.com/NOAA-EMC/NCEPLIBS-g2c/archive/refs/tags/v${G2C_VERSION}.tar.gz" \ + -O g2c.tar.gz \ + && tar xzf g2c.tar.gz \ + && cd NCEPLIBS-g2c-${G2C_VERSION} \ + && mkdir build && cd build \ + && cmake .. -DCMAKE_INSTALL_PREFIX=/usr/local \ + -DCMAKE_BUILD_TYPE=Release \ + -DUSE_PNG=ON \ + -DUSE_AEC=ON \ + -DUSE_OpenJPEG=ON \ + -DUSE_Jasper=OFF \ + && make -j$(nproc) \ + && make install + +WORKDIR /tmp/wgrib2 +RUN wget -q --content-disposition "https://github.com/NOAA-EMC/wgrib2/archive/refs/tags/v${WGRIB2_VERSION}.tar.gz" \ + -O wgrib2.tar.gz \ + && tar xzf wgrib2.tar.gz \ + && cd wgrib2-${WGRIB2_VERSION} \ + && mkdir build && cd build \ + && cmake .. -DCMAKE_INSTALL_PREFIX=/usr/local -DCMAKE_BUILD_TYPE=Release \ + -DUSE_AEC=ON -DUSE_G2CLIB_LOW=ON \ + && make -j$(nproc) \ + && make install \ + && strip /usr/local/bin/wgrib2 + +# ---- Final runtime base ---- +FROM ${RUNNER_IMAGE} AS base + +# Runtime apt deps. cdo + gdal-bin are split into a second RUN so a +# transient cdo install failure doesn't invalidate the smaller base +# layer above. +RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,target=/var/lib/apt,sharing=locked \ + rm -f /etc/apt/apt.conf.d/docker-clean \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + libstdc++6 openssl libncurses6 locales ca-certificates snmp \ + libgfortran5 libaec0 zlib1g libpng16-16t64 libopenjp2-7 + +RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,target=/var/lib/apt,sharing=locked \ + rm -f /etc/apt/apt.conf.d/docker-clean \ + && apt-get update \ + && apt-get install -y --no-install-recommends cdo gdal-bin + +RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen + +ENV LANG=en_US.UTF-8 +ENV LANGUAGE=en_US:en +ENV LC_ALL=en_US.UTF-8 + +# wgrib2 binary + dynamic g2c lib. ldconfig refreshes the loader cache +# so /usr/local/lib is searched at exec time without LD_LIBRARY_PATH. +COPY --from=wgrib2-builder /usr/local/bin/wgrib2 /usr/local/bin/wgrib2 +COPY --from=wgrib2-builder /usr/local/lib/libg2c.so* /usr/local/lib/ +RUN ldconfig