From d145704f2f92dfc77246b0291b590fdceb3bfcb8 Mon Sep 17 00:00:00 2001 From: Jeremy Poulter Date: Wed, 23 Sep 2026 18:46:13 +0100 Subject: [PATCH 1/5] Cross-compile PHP extensions and publish amd64/arm64 images --- .github/workflows/docker-release.yml | 212 +++++++++++++++++++-------- .gitignore | 1 + ci/build-extension-artifacts.sh | 43 ++++++ ci/build-php-extensions.sh | 130 ++++++++++++++++ ci/extract-php-sdk.sh | 28 ++++ web/Dockerfile | 20 ++- web/README.md | 22 ++- web/extensions.ini | 4 + web/install_mosquitto.sh | 13 -- web/install_redis.sh | 11 -- 10 files changed, 384 insertions(+), 100 deletions(-) create mode 100755 ci/build-extension-artifacts.sh create mode 100755 ci/build-php-extensions.sh create mode 100755 ci/extract-php-sdk.sh create mode 100644 web/extensions.ini delete mode 100644 web/install_mosquitto.sh delete mode 100644 web/install_redis.sh diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index 03f0b8d..d7d529c 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -5,20 +5,20 @@ on: workflow_dispatch: inputs: php_version: - description: 'php_version' + description: php_version required: true type: string - default: '8.4' + default: "8.4" emoncms_src: - description: 'emoncms_src' + description: emoncms_src required: true type: string - default: 'emoncms/emoncms' + default: emoncms/emoncms branch: - description: 'branch' + description: branch required: true type: string - default: 'stable' + default: stable env: PHP_VERSION: ${{ inputs.php_version || '8.4' }} @@ -26,66 +26,150 @@ env: EMONCMS_BRANCH: ${{ inputs.branch || 'stable' }} jobs: + metadata: + runs-on: ubuntu-latest + outputs: + dockerhub_available: ${{ steps.registry.outputs.available }} + php_image: ${{ steps.php.outputs.image }} + tags: ${{ steps.tags.outputs.tags }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Resolve PHP image + id: php + shell: bash + run: | + set -euo pipefail + tag="php:${PHP_VERSION}-apache" + digest=$(docker buildx imagetools inspect "$tag" --format '{{.Manifest.Digest}}') + case "$digest" in + sha256:*) ;; + *) printf 'Invalid PHP image digest: %s\n' "$digest" >&2; exit 1 ;; + esac + echo "image=${tag}@${digest}" >> "$GITHUB_OUTPUT" + + - name: Check for Docker Hub credentials + id: registry + env: + DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} + DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} + run: | + if [ -n "$DOCKER_USERNAME" ] && [ -n "$DOCKER_PASSWORD" ]; then + echo "available=true" >> "$GITHUB_OUTPUT" + else + echo "available=false" >> "$GITHUB_OUTPUT" + fi + + - name: Get emoncms version + id: emoncms_version + run: | + set -euo pipefail + url="https://raw.githubusercontent.com/${EMONCMS_SRC}/${EMONCMS_BRANCH}/version.json" + version=$(curl -fsSL "$url" | jq --raw-output '.version') + echo "$version" + echo "version=$version" >> "$GITHUB_OUTPUT" + + - name: Set image tags + id: tags + run: | + VERSION="${{ steps.emoncms_version.outputs.version }}" + if [ "${{ github.event_name }}" = "pull_request" ]; then + TAGS="ghcr.io/${{ github.repository_owner }}/emoncms:pr-${{ github.event.number }}" + elif [ "${{ steps.registry.outputs.available }}" = "true" ]; then + TAGS="openenergymonitor/emoncms:latest,openenergymonitor/emoncms:${VERSION}" + else + TAGS="ghcr.io/${{ github.repository_owner }}/emoncms:latest,ghcr.io/${{ github.repository_owner }}/emoncms:${VERSION}" + fi + echo "tags=$TAGS" >> "$GITHUB_OUTPUT" + + extensions: + needs: metadata + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + architecture: [amd64, arm64] + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Build PHP extensions + run: | + ci/build-extension-artifacts.sh \ + "${{ needs.metadata.outputs.php_image }}" \ + "${{ matrix.architecture }}" \ + "$RUNNER_TEMP/extensions" + + - name: Upload PHP extensions + uses: actions/upload-artifact@v4 + with: + name: php-extensions-${{ matrix.architecture }} + path: ${{ runner.temp }}/extensions/ + if-no-files-found: error + retention-days: 7 + build-and-push: + needs: [metadata, extensions] runs-on: ubuntu-latest permissions: + contents: read packages: write steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Check for Docker Hub credentials - id: check_dockerhub - run: | - if [ -n "${{ secrets.DOCKER_USERNAME }}" ] && [ -n "${{ secrets.DOCKER_PASSWORD }}" ]; then - echo "available=true" >> "$GITHUB_OUTPUT" - else - echo "available=false" >> "$GITHUB_OUTPUT" - fi - - - name: Log in to Docker Hub - if: steps.check_dockerhub.outputs.available == 'true' && github.event_name != 'pull_request' - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_PASSWORD }} - - - name: Log in to GitHub Container Registry - if: steps.check_dockerhub.outputs.available != 'true' || github.event_name == 'pull_request' - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Get emoncms version - id: emoncms_version - run: | - wget https://raw.githubusercontent.com/${{ env.EMONCMS_SRC }}/${{ env.EMONCMS_BRANCH }}/version.json - version=$(cat version.json | jq --raw-output '.version') - echo $version - echo "version=$version" >> "$GITHUB_OUTPUT" - - - name: Set image tags - id: tags - run: | - VERSION="${{ steps.emoncms_version.outputs.version }}" - if [ "${{ github.event_name }}" = "pull_request" ]; then - TAGS="ghcr.io/${{ github.repository_owner }}/emoncms:pr-${{ github.event.number }}" - elif [ "${{ steps.check_dockerhub.outputs.available }}" = "true" ]; then - TAGS="openenergymonitor/emoncms:latest,openenergymonitor/emoncms:${VERSION}" - else - TAGS="ghcr.io/${{ github.repository_owner }}/emoncms:latest,ghcr.io/${{ github.repository_owner }}/emoncms:${VERSION}" - fi - echo "tags=$TAGS" >> "$GITHUB_OUTPUT" - - - name: Build and push Docker image - uses: docker/build-push-action@v5 - with: - context: ./web - build-args: | - "BUILD_FROM=php:${{ env.PHP_VERSION }}-apache" - "EMONCMS_SRC=https://github.com/${{ env.EMONCMS_SRC }}" - "BRANCH=${{ env.EMONCMS_BRANCH }}" - push: true - tags: ${{ steps.tags.outputs.tags }} + - name: Checkout code + uses: actions/checkout@v4 + + - name: Download amd64 PHP extensions + uses: actions/download-artifact@v4 + with: + name: php-extensions-amd64 + path: web/extensions/linux-amd64 + + - name: Download arm64 PHP extensions + uses: actions/download-artifact@v4 + with: + name: php-extensions-arm64 + path: web/extensions/linux-arm64 + + - name: Check extension artifacts + run: | + set -euo pipefail + test "$(find web/extensions -name '*.so' -type f | wc -l)" -eq 8 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + with: + platforms: arm64 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + if: needs.metadata.outputs.dockerhub_available == 'true' && github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Log in to GitHub Container Registry + if: needs.metadata.outputs.dockerhub_available != 'true' && github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: ./web + platforms: linux/amd64,linux/arm64 + build-args: | + BUILD_FROM=${{ needs.metadata.outputs.php_image }} + EMONCMS_SRC=https://github.com/${{ env.EMONCMS_SRC }} + BRANCH=${{ env.EMONCMS_BRANCH }} + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ needs.metadata.outputs.tags }} diff --git a/.gitignore b/.gitignore index 41a24fc..12ea2b0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ emoncms/ .docker-env *~ +web/extensions/ diff --git a/ci/build-extension-artifacts.sh b/ci/build-extension-artifacts.sh new file mode 100755 index 0000000..ee0da1a --- /dev/null +++ b/ci/build-extension-artifacts.sh @@ -0,0 +1,43 @@ +#!/bin/bash +set -euo pipefail + +image=${1:?usage: build-extension-artifacts.sh } +architecture=${2:?missing architecture} +output=${3:?missing output directory} +root=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) +sdk=$(mktemp -d) +trap 'rm -rf "$sdk"' EXIT + +case "$architecture" in + amd64) + platform=linux/amd64 + packages="gcc g++ libc6-dev libmosquitto-dev" + ;; + arm64) + platform=linux/arm64 + packages="gcc-aarch64-linux-gnu g++-aarch64-linux-gnu libc6-dev:arm64 libmosquitto-dev:arm64" + ;; + *) + printf 'Unsupported architecture: %s\n' "$architecture" >&2 + exit 2 + ;; +esac + +"$root/ci/extract-php-sdk.sh" "$image" "$platform" "$sdk" +suite=$(sh -c '. "$1"; printf "%s" "$VERSION_CODENAME"' sh "$sdk/rootfs/etc/os-release") +mkdir -p "$output" + +docker run --rm --platform linux/amd64 \ + -v "$root:/workspace" \ + -v "$sdk/rootfs:/php-root:ro" \ + -v "$output:/out" \ + -w /workspace \ + "debian:$suite" \ + bash -euxo pipefail -c " + dpkg --add-architecture arm64 + apt-get update + apt-get install -y --no-install-recommends \\ + autoconf automake binutils ca-certificates file git libtool make pkg-config xz-utils \\ + $packages + ./ci/build-php-extensions.sh '$architecture' /php-root /out + " diff --git a/ci/build-php-extensions.sh b/ci/build-php-extensions.sh new file mode 100755 index 0000000..ef69368 --- /dev/null +++ b/ci/build-php-extensions.sh @@ -0,0 +1,130 @@ +#!/bin/bash +set -euo pipefail + +target_arch=${1:?usage: build-php-extensions.sh } +php_root=${2:?missing extracted PHP root} +output=${3:?missing output directory} + +PHPREDIS_COMMIT=df4fab2de7fc327c54c94a13af2b9542e4fbd720 +MOSQUITTO_PHP_COMMIT=426a08afc452a5779a1404ffb875aed375431dfa +PHP_API_EXPECTED=20240924 + +case "$target_arch" in + amd64) + host=x86_64-linux-gnu + multiarch=x86_64-linux-gnu + CC="gcc" + CXX="g++" + AR="ar" + RANLIB="ranlib" + STRIP="strip" + READELF="readelf" + expected_machine="Advanced Micro Devices X86-64" + ;; + arm64) + host=aarch64-linux-gnu + multiarch=aarch64-linux-gnu + CC=aarch64-linux-gnu-gcc + CXX=aarch64-linux-gnu-g++ + AR=aarch64-linux-gnu-ar + RANLIB=aarch64-linux-gnu-ranlib + STRIP=aarch64-linux-gnu-strip + READELF=aarch64-linux-gnu-readelf + expected_machine=AArch64 + ;; + *) + printf 'Unsupported target architecture: %s\n' "$target_arch" >&2 + exit 2 + ;; +esac + +export CC CXX AR RANLIB STRIP +export CFLAGS="-O2 -fPIC -fstack-protector-strong -D_LARGEFILE_SOURCE -D_FILE_OFFSET_BITS=64" +export CPPFLAGS="$CFLAGS" +export LDFLAGS="-Wl,-O1 -Wl,-z,relro -Wl,-z,now" + +php_headers=$php_root/usr/local/include/php +php_api=$(sed -n 's/^#define ZEND_MODULE_API_NO //p' "$php_headers/Zend/zend_modules.h") +test "$php_api" = "$PHP_API_EXPECTED" + +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +mkdir -p "$output" "$work/sdk" + +sed "s|/usr/local|$php_root/usr/local|g" \ + "$php_root/usr/local/bin/phpize" > "$work/sdk/phpize" +sed "s|/usr/local|$php_root/usr/local|g" \ + "$php_root/usr/local/bin/php-config" > "$work/sdk/php-config" +chmod +x "$work/sdk/phpize" "$work/sdk/php-config" +phpize=$work/sdk/phpize +php_config=$work/sdk/php-config + +clone_commit() { + repository=$1 + commit=$2 + destination=$3 + git init -q "$destination" + git -C "$destination" remote add origin "$repository" + git -C "$destination" fetch -q --depth=1 origin "$commit" + git -C "$destination" checkout -q --detach FETCH_HEAD + test "$(git -C "$destination" rev-parse HEAD)" = "$commit" +} + +build_extension() { + source_directory=$1 + output_name=$2 + shift 2 + ( + cd "$source_directory" + "$phpize" + ./configure \ + --build=x86_64-linux-gnu \ + --host="$host" \ + --with-php-config="$php_config" \ + --with-libdir="lib/$multiarch" \ + "$@" + make -j"$(nproc)" + "$STRIP" --strip-unneeded "modules/$output_name.so" + cp "modules/$output_name.so" "$output/$output_name.so" + ) +} + +clone_commit https://github.com/phpredis/phpredis.git \ + "$PHPREDIS_COMMIT" "$work/phpredis" +build_extension "$work/phpredis" redis \ + --disable-redis-igbinary \ + --disable-redis-msgpack \ + --disable-redis-lzf \ + --disable-redis-zstd \ + --disable-redis-lz4 + +clone_commit https://github.com/openenergymonitor/Mosquitto-PHP.git \ + "$MOSQUITTO_PHP_COMMIT" "$work/mosquitto-php" +build_extension "$work/mosquitto-php" mosquitto --with-mosquitto=/usr + +mkdir "$work/php-src" +tar -C "$work/php-src" --strip-components=1 -xf "$php_root/usr/src/php.tar.xz" +build_extension "$work/php-src/ext/mysqli" mysqli --with-mysqli=mysqlnd +build_extension "$work/php-src/ext/gettext" gettext --with-gettext + +for extension in mysqli gettext redis mosquitto; do + shared_object=$output/$extension.so + test -s "$shared_object" + "$READELF" -h "$shared_object" | grep -F "Machine:" | grep -F "$expected_machine" + "$READELF" --dyn-syms --wide "$shared_object" | grep get_module >/dev/null + if "$READELF" -d "$shared_object" | grep -Eq 'RUNPATH|RPATH'; then + printf '%s contains an unexpected runtime search path\n' "$shared_object" >&2 + exit 1 + fi +done + +"$READELF" -d "$output/mosquitto.so" | grep -F 'Shared library: [libmosquitto.so.1]' + +{ + printf 'target_arch=%s\n' "$target_arch" + printf 'target_triplet=%s\n' "$host" + printf 'php_api=%s\n' "$php_api" + printf 'phpredis_commit=%s\n' "$PHPREDIS_COMMIT" + printf 'mosquitto_php_commit=%s\n' "$MOSQUITTO_PHP_COMMIT" + sha256sum "$output"/*.so +} > "$output/build-metadata.txt" diff --git a/ci/extract-php-sdk.sh b/ci/extract-php-sdk.sh new file mode 100755 index 0000000..dd33ff7 --- /dev/null +++ b/ci/extract-php-sdk.sh @@ -0,0 +1,28 @@ +#!/bin/bash +set -euo pipefail + +image=${1:?usage: extract-php-sdk.sh } +platform=${2:?missing platform} +destination=${3:?missing destination} + +rm -rf "$destination" +mkdir -p "$destination/rootfs/usr/local/bin" \ + "$destination/rootfs/usr/local/include" \ + "$destination/rootfs/usr/local/lib/php" \ + "$destination/rootfs/usr/src" \ + "$destination/rootfs/etc" + +docker pull --platform "$platform" "$image" +container=$(docker create --platform "$platform" "$image") +trap 'docker rm -f "$container" >/dev/null 2>&1 || true' EXIT + +docker cp "$container:/usr/local/bin/phpize" "$destination/rootfs/usr/local/bin/phpize" +docker cp "$container:/usr/local/bin/php-config" "$destination/rootfs/usr/local/bin/php-config" +docker cp "$container:/usr/local/include/php" "$destination/rootfs/usr/local/include/php" +docker cp "$container:/usr/local/lib/php/build" "$destination/rootfs/usr/local/lib/php/build" +docker cp "$container:/usr/src/php.tar.xz" "$destination/rootfs/usr/src/php.tar.xz" +docker cp "$container:/usr/lib/os-release" "$destination/rootfs/etc/os-release" + +test -f "$destination/rootfs/usr/local/include/php/main/php_config.h" +test -f "$destination/rootfs/usr/local/include/php/Zend/zend_modules.h" +test -f "$destination/rootfs/usr/src/php.tar.xz" diff --git a/web/Dockerfile b/web/Dockerfile index 54c2b2f..01eaa5a 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -5,6 +5,7 @@ FROM $BUILD_FROM ARG \ TARGETPLATFORM \ + TARGETARCH \ S6_OVERLAY_VERSION=3.1.6.2 \ S6_SRC=https://github.com/just-containers/s6-overlay/releases/download \ S6_DIR=/etc/s6-overlay/s6-rc.d \ @@ -30,7 +31,7 @@ ENV \ RUN apt-get update && apt-get install -y --no-install-recommends \ iproute2 \ libcurl4-gnutls-dev \ - libmosquitto-dev \ + libmosquitto1 \ gettext \ nano \ git-core \ @@ -41,16 +42,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ curl \ && rm -rf /var/lib/apt/lists/* -# Enable PHP modules -RUN docker-php-ext-install -j$(nproc) mysqli gettext - -# Install phpredis extension -COPY install_redis.sh / -RUN chmod +x /install_redis.sh && /install_redis.sh - -# Install Mosquitto-PHP extension -COPY install_mosquitto.sh / -RUN chmod +x /install_mosquitto.sh && /install_mosquitto.sh +# Native extensions are cross-compiled and validated before image assembly. +COPY extensions/linux-${TARGETARCH}/*.so /usr/local/lib/php/extensions/emonos/ +COPY extensions.ini /usr/local/etc/php/conf.d/30-emonos-extensions.ini +RUN set -eux; \ + php -r 'foreach (["mysqli", "gettext", "redis", "mosquitto"] as $extension) { if (!extension_loaded($extension)) { exit(1); } }'; \ + php -r 'if (!class_exists("Redis") || !class_exists("Mosquitto\\Client")) { exit(1); }'; \ + ! ldd /usr/local/lib/php/extensions/emonos/*.so | grep -q "not found" RUN a2enmod rewrite diff --git a/web/README.md b/web/README.md index 485f724..9d26e03 100644 --- a/web/README.md +++ b/web/README.md @@ -1,7 +1,27 @@ # how to build +The PHP extensions (mysqli, gettext, phpredis and Mosquitto-PHP) are compiled +outside the image and copied in as prebuilt `.so` files. The arm64 build uses a +cross toolchain rather than emulation. Build them for each target architecture +before building the image, using the same `BUILD_FROM` image: + +``` +BUILD_FROM=php:8.4-apache +ci/build-extension-artifacts.sh "$BUILD_FROM" amd64 web/extensions/linux-amd64 +ci/build-extension-artifacts.sh "$BUILD_FROM" arm64 web/extensions/linux-arm64 +``` + +Run these from the repository root. Then build the image: + +``` +docker build --build-arg="BUILD_FROM=$BUILD_FROM" -t emoncms_legacy_docker web +``` + +For a multi-platform image: + ``` -docker build --build-arg="BUILD_FROM=php:8.2.27-apache" -t emoncms_legacy_docker . +docker buildx build --platform linux/amd64,linux/arm64 \ + --build-arg="BUILD_FROM=$BUILD_FROM" -t emoncms_legacy_docker web ``` ## Database schema on first boot diff --git a/web/extensions.ini b/web/extensions.ini new file mode 100644 index 0000000..0fe84f5 --- /dev/null +++ b/web/extensions.ini @@ -0,0 +1,4 @@ +extension=/usr/local/lib/php/extensions/emonos/mysqli.so +extension=/usr/local/lib/php/extensions/emonos/gettext.so +extension=/usr/local/lib/php/extensions/emonos/redis.so +extension=/usr/local/lib/php/extensions/emonos/mosquitto.so diff --git a/web/install_mosquitto.sh b/web/install_mosquitto.sh deleted file mode 100644 index 92f0096..0000000 --- a/web/install_mosquitto.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/bash - -# Build and install Mosquitto-PHP extension -# Requires libmosquitto-dev to be pre-installed -cd / -git clone https://github.com/openenergymonitor/Mosquitto-PHP -cd Mosquitto-PHP/ -phpize -./configure -make -make install -docker-php-ext-enable mosquitto - diff --git a/web/install_redis.sh b/web/install_redis.sh deleted file mode 100644 index f901c85..0000000 --- a/web/install_redis.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/bin/bash - -# Install Redis -cd / -git clone https://github.com/phpredis/phpredis -cd phpredis -phpize -./configure -make -make install -docker-php-ext-enable redis From beaa847ac548c119dd3b770a5a0d0d2d7484cedc Mon Sep 17 00:00:00 2001 From: Jeremy Poulter Date: Wed, 23 Sep 2026 19:02:54 +0100 Subject: [PATCH 2/5] Update GitHub Actions to latest major versions --- .github/workflows/docker-release.yml | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index d7d529c..b8ac2db 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -34,10 +34,10 @@ jobs: tags: ${{ steps.tags.outputs.tags }} steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Resolve PHP image id: php @@ -95,7 +95,7 @@ jobs: architecture: [amd64, arm64] steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Build PHP extensions run: | @@ -105,7 +105,7 @@ jobs: "$RUNNER_TEMP/extensions" - name: Upload PHP extensions - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: php-extensions-${{ matrix.architecture }} path: ${{ runner.temp }}/extensions/ @@ -120,16 +120,16 @@ jobs: packages: write steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Download amd64 PHP extensions - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: php-extensions-amd64 path: web/extensions/linux-amd64 - name: Download arm64 PHP extensions - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: php-extensions-arm64 path: web/extensions/linux-arm64 @@ -140,30 +140,30 @@ jobs: test "$(find web/extensions -name '*.so' -type f | wc -l)" -eq 8 - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@v4 with: platforms: arm64 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Log in to Docker Hub if: needs.metadata.outputs.dockerhub_available == 'true' && github.event_name != 'pull_request' - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Log in to GitHub Container Registry if: needs.metadata.outputs.dockerhub_available != 'true' && github.event_name != 'pull_request' - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push Docker image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: ./web platforms: linux/amd64,linux/arm64 From c500fdbe419f99cc4c62dac18540fbc0ab1f3939 Mon Sep 17 00:00:00 2001 From: Jeremy Poulter Date: Thu, 24 Sep 2026 06:35:50 +0100 Subject: [PATCH 3/5] Pin the PHP base image to Debian trixie --- .github/workflows/docker-release.yml | 4 +++- web/Dockerfile | 2 +- web/README.md | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index b8ac2db..61f0ac3 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -24,6 +24,8 @@ env: PHP_VERSION: ${{ inputs.php_version || '8.4' }} EMONCMS_SRC: ${{ inputs.emoncms_src || 'emoncms/emoncms' }} EMONCMS_BRANCH: ${{ inputs.branch || 'stable' }} + # Debian release of the php:-apache- base image + DEBIAN_SUITE: trixie jobs: metadata: @@ -44,7 +46,7 @@ jobs: shell: bash run: | set -euo pipefail - tag="php:${PHP_VERSION}-apache" + tag="php:${PHP_VERSION}-apache-${DEBIAN_SUITE}" digest=$(docker buildx imagetools inspect "$tag" --format '{{.Manifest.Digest}}') case "$digest" in sha256:*) ;; diff --git a/web/Dockerfile b/web/Dockerfile index 01eaa5a..ee0d83d 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -1,4 +1,4 @@ -ARG BUILD_FROM=php:8.4-apache +ARG BUILD_FROM=php:8.4-apache-trixie # Official Docker PHP & Apache image https://hub.docker.com/_/php/ FROM $BUILD_FROM diff --git a/web/README.md b/web/README.md index 9d26e03..7c021d3 100644 --- a/web/README.md +++ b/web/README.md @@ -6,7 +6,7 @@ cross toolchain rather than emulation. Build them for each target architecture before building the image, using the same `BUILD_FROM` image: ``` -BUILD_FROM=php:8.4-apache +BUILD_FROM=php:8.4-apache-trixie ci/build-extension-artifacts.sh "$BUILD_FROM" amd64 web/extensions/linux-amd64 ci/build-extension-artifacts.sh "$BUILD_FROM" arm64 web/extensions/linux-arm64 ``` From 19bd4f5d4a20d318534dd3c66548ecdce2edf1b0 Mon Sep 17 00:00:00 2001 From: Jeremy Poulter Date: Thu, 24 Sep 2026 18:39:06 +0100 Subject: [PATCH 4/5] Document extension prebuild and manual image publishing --- README.md | 41 ++++++++++++++++++++++++----------------- 1 file changed, 24 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 8439080..fca5eec 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ emonHub docker: https://hub.docker.com/r/alexjunk/emonhub **Note: This docker installation is not quite a complete Emoncms installation.** In general we recommend building an Emoncms installation using our EmonScripts installation script on a Debian/Ubuntu/RaspberryPi based system, this said this docker image does provide a useful alternative approach to get a simple but functional emoncms installation up and running. -Latest image hosted on docker hub: [openenergymonitor/emoncms:latest](https://hub.docker.com/r/openenergymonitor/emoncms/) +Latest image hosted on docker hub: [openenergymonitor/emoncms:latest](https://hub.docker.com/r/openenergymonitor/emoncms/) (`linux/amd64` and `linux/arm64`) ## Quickstart @@ -116,7 +116,16 @@ Edit `config/php.ini` to add custom php settings e.g. timezone (default Europe) #### Build / update Docker container -Required on first run or if `Dockerfile` or `Docker-compose.yml` are changed: +Required on first run or if `Dockerfile` or `Docker-compose.yml` are changed. + +The PHP extensions are not compiled inside the image. Build them first for your +architecture (`amd64` or `arm64`), from the repository root: + +```bash +ci/build-extension-artifacts.sh php:8.4-apache-trixie amd64 web/extensions/linux-amd64 +``` + +See [web/README.md](web/README.md) for details. Then: ```bash docker-compose build @@ -272,21 +281,19 @@ docker exec -it emoncms-docker_web_1 /bin/bash **** -## Pushing to docker hub +## Publishing images -From: https://docs.docker.com/docker-hub/repos/ +Images are built and published by the +[Build and Push emoncms Docker Image](.github/workflows/docker-release.yml) workflow: -```bash -docker login --username=yourhubusername --email=youremail@company.com -docker tag openenergymonitor/emoncms: -docker push openenergymonitor/emoncms: -``` - -Tag name should be the Emoncms version e.g 10.x.x - -Also push the latest version using `latest` tag +- **Pull requests** build both platforms (`linux/amd64`, `linux/arm64`) but do not push. +- **Publishing is manual.** Run the workflow from the Actions tab (`workflow_dispatch`), + optionally choosing the PHP version, emoncms repository and branch. Pushing to `master` + does not publish an image. +- If the `DOCKER_USERNAME` and `DOCKER_PASSWORD` secrets are set, the workflow pushes + `openenergymonitor/emoncms:latest` and `openenergymonitor/emoncms:` to + Docker Hub. Otherwise, for example on a fork, it pushes the same tags to + `ghcr.io//emoncms`. -```bash -docker tag openenergymonitor/emoncms:latest -docker tag openenergymonitor/emoncms:latest -``` +The PHP extensions are cross-compiled per architecture in a separate job, so the arm64 +build does not run a compiler under emulation. From 815e0f8c5d51374f1c2865667caf65ba21e64651 Mon Sep 17 00:00:00 2001 From: Jeremy Poulter Date: Thu, 24 Sep 2026 19:00:53 +0100 Subject: [PATCH 5/5] Build and publish the image on push to master --- .github/workflows/docker-release.yml | 8 ++++++++ README.md | 7 ++++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index 61f0ac3..974cca6 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -1,6 +1,8 @@ name: Build and Push emoncms Docker Image on: + push: + branches: [master] pull_request: workflow_dispatch: inputs: @@ -27,6 +29,12 @@ env: # Debian release of the php:-apache- base image DEBIAN_SUITE: trixie +# Publishing runs queue so an older build cannot overwrite a newer :latest. +# Superseded pull request builds are cancelled. +concurrency: + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.ref || 'publish' }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: metadata: runs-on: ubuntu-latest diff --git a/README.md b/README.md index fca5eec..38943cc 100644 --- a/README.md +++ b/README.md @@ -287,9 +287,10 @@ Images are built and published by the [Build and Push emoncms Docker Image](.github/workflows/docker-release.yml) workflow: - **Pull requests** build both platforms (`linux/amd64`, `linux/arm64`) but do not push. -- **Publishing is manual.** Run the workflow from the Actions tab (`workflow_dispatch`), - optionally choosing the PHP version, emoncms repository and branch. Pushing to `master` - does not publish an image. +- **Pushes to `master`** build and publish the image using the defaults (PHP 8.4, the + `stable` branch of `emoncms/emoncms`). +- **Manual runs** from the Actions tab (`workflow_dispatch`) also publish, and let you + choose the PHP version, emoncms repository and branch. - If the `DOCKER_USERNAME` and `DOCKER_PASSWORD` secrets are set, the workflow pushes `openenergymonitor/emoncms:latest` and `openenergymonitor/emoncms:` to Docker Hub. Otherwise, for example on a fork, it pushes the same tags to