diff --git a/.github/workflows/build-base.yml b/.github/workflows/build-base.yml new file mode 100644 index 0000000..891fa6b --- /dev/null +++ b/.github/workflows/build-base.yml @@ -0,0 +1,202 @@ +name: Build PHP base image from php.net tarball + +# Builds the docker-library php:-fpm-alpine image ourselves, with a PHP +# version that the official images do not have yet (security releases). +# Result: /php-base:-fpm-alpine and /php-base:-fpm-alpine. + +on: + workflow_dispatch: + inputs: + php_version: + description: 'PHP versions, space or comma separated (e.g. "8.2.35 8.3.28")' + required: true + php_sha256: + description: 'sha256 of each php-.tar.xz, same order as php_version' + required: true + tag_prefix: + description: 'Tag prefix (e.g. "test-"). Empty = release tags.' + required: false + default: '' + alpine: + description: 'docker-library alpine directory' + required: false + default: 'alpine3.24' + # Dry run only: a workflow must be on the default branch before it can be dispatched. + # Add the "test-base-image" label to a PR. The run builds 8.2.34 and pushes test- tags only. + pull_request: + types: [labeled] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +env: + IMAGE: ${{ vars.DOCKER_ORG_USERNAME || 'meghsh' }}/php-base + PHP_VERSIONS: ${{ github.event_name == 'pull_request' && '8.2.34' || inputs.php_version }} + PHP_SHA256S: ${{ github.event_name == 'pull_request' && '5351330c54de240f54f7527c26ef292e239749e6fe11db0330acb5317e02bb39' || inputs.php_sha256 }} + TAG_PREFIX: ${{ github.event_name == 'pull_request' && 'test-' || inputs.tag_prefix }} + ALPINE: ${{ inputs.alpine || 'alpine3.24' }} + +jobs: + setup: + if: ${{ github.event_name != 'pull_request' || github.event.label.name == 'test-base-image' }} + runs-on: ubuntu-latest + outputs: + releases: ${{ steps.matrix.outputs.releases }} + upstream_sha: ${{ steps.matrix.outputs.upstream_sha }} + steps: + - name: Create release matrix + id: matrix + env: + GH_TOKEN: ${{ github.token }} + run: | + set -o pipefail + read -ra versions <<< "${PHP_VERSIONS//,/ }" + read -ra sums <<< "${PHP_SHA256S//,/ }" + if [ "${#versions[@]}" -eq 0 ] || [ "${#versions[@]}" -ne "${#sums[@]}" ]; then + echo "::error::php_version and php_sha256 must have the same number of items" + exit 1 + fi + releases=$(for i in "${!versions[@]}"; do + [[ "${versions[$i]}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "::error::Bad version ${versions[$i]}" >&2; exit 1; } + [[ "${sums[$i]}" =~ ^[0-9a-f]{64}$ ]] || { echo "::error::Bad sha256 ${sums[$i]}" >&2; exit 1; } + jq -nc --arg version "${versions[$i]}" --arg sha256 "${sums[$i]}" \ + '{version: $version, sha256: $sha256, minor: ($version | split(".")[0:2] | join("."))}' + done | jq -sc .) + upstream_sha=$(gh api repos/docker-library/php/commits/master --jq .sha) + echo "releases=$releases" >> "$GITHUB_OUTPUT" + echo "upstream_sha=$upstream_sha" >> "$GITHUB_OUTPUT" + echo "Releases: $releases" + echo "docker-library/php commit: $upstream_sha" + + build: + name: build (${{ matrix.release.version }}, ${{ matrix.arch }}) + needs: setup + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + release: ${{ fromJSON(needs.setup.outputs.releases) }} + platform: [linux/amd64, linux/arm64] + include: + - platform: linux/amd64 + arch: amd64 + runner: ubuntu-latest + - platform: linux/arm64 + arch: arm64 + runner: ubuntu-24.04-arm + env: + VERSION: ${{ matrix.release.version }} + SHA256: ${{ matrix.release.sha256 }} + MINOR: ${{ matrix.release.minor }} + steps: + - name: Fetch docker-library files + env: + GH_TOKEN: ${{ github.token }} + UPSTREAM_SHA: ${{ needs.setup.outputs.upstream_sha }} + run: | + mkdir base + gh api "repos/docker-library/php/contents/${MINOR}/${ALPINE}/fpm?ref=${UPSTREAM_SHA}" \ + --jq '.[] | select(.type == "file") | .download_url' \ + | while read -r url; do curl -fsSL --retry 5 -o "base/$(basename "$url")" "$url"; done + chmod +x base/docker-php-* + ls -l base + + - name: Set PHP version in Dockerfile + working-directory: base + run: | + url="https://www.php.net/distributions/php-${VERSION}.tar.xz" + version_line="ENV PHP_VERSION ${VERSION}" + url_line="ENV PHP_URL=\"${url}\" PHP_ASC_URL=\"${url}.asc\"" + sha_line="ENV PHP_SHA256=\"${SHA256}\"" + sed -i \ + -e "s|^ENV PHP_VERSION .*|${version_line}|" \ + -e "s|^ENV PHP_URL=.*|${url_line}|" \ + -e "s|^ENV PHP_SHA256=.*|${sha_line}|" \ + Dockerfile + for line in "$version_line" "$url_line" "$sha_line"; do + grep -Fxq "$line" Dockerfile || { echo "::error::Dockerfile does not contain: $line"; exit 1; } + done + grep -q 'gpg --batch --verify php.tar.xz.asc php.tar.xz' Dockerfile || { echo "::error::GPG verification is missing"; exit 1; } + grep -E '^ENV (GPG_KEYS|PHP_VERSION|PHP_URL|PHP_SHA256)' Dockerfile + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Build and push by digest + id: build + uses: docker/build-push-action@v5 + with: + context: base + platforms: ${{ matrix.platform }} + outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true + + - name: Check PHP version in image + env: + DIGEST: ${{ steps.build.outputs.digest }} + run: | + actual=$(docker run --rm "${IMAGE}@${DIGEST}" php -r 'echo PHP_VERSION;') + echo "Image PHP version: $actual" + [ "$actual" = "$VERSION" ] || { echo "::error::Expected PHP $VERSION, got $actual"; exit 1; } + docker run --rm "${IMAGE}@${DIGEST}" php-fpm -v + + - name: Export digest + env: + DIGEST: ${{ steps.build.outputs.digest }} + run: | + mkdir -p /tmp/digests + touch "/tmp/digests/${DIGEST#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@v4 + with: + name: digests-${{ matrix.release.minor }}-${{ matrix.arch }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + merge: + name: merge (${{ matrix.release.version }}) + needs: [setup, build] + # Keep the other PHP versions going when one build fails. + if: ${{ !cancelled() && needs.setup.result == 'success' }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + release: ${{ fromJSON(needs.setup.outputs.releases) }} + steps: + - name: Download digests + uses: actions/download-artifact@v4 + with: + path: /tmp/digests + pattern: digests-${{ matrix.release.minor }}-* + merge-multiple: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Create manifest list and push + working-directory: /tmp/digests + env: + MINOR: ${{ matrix.release.minor }} + VERSION: ${{ matrix.release.version }} + run: | + [ "$(ls | wc -l)" -eq 2 ] || { echo "::error::Expected 2 digests, got $(ls | wc -l)"; exit 1; } + docker buildx imagetools create \ + -t "${IMAGE}:${TAG_PREFIX}${MINOR}-fpm-alpine" \ + -t "${IMAGE}:${TAG_PREFIX}${VERSION}-fpm-alpine" \ + $(printf "${IMAGE}@sha256:%s " *) + docker buildx imagetools inspect "${IMAGE}:${TAG_PREFIX}${VERSION}-fpm-alpine" diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 25f51ea..67b16ae 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -2,6 +2,24 @@ name: Build, Test, and Push Docker images on: workflow_dispatch: + inputs: + php_versions: + description: 'PHP minors to build, space or comma separated (e.g. "8.2 8.3"). Empty = all.' + required: false + default: '' + base_image: + description: 'Base image prefix. The base is -fpm-alpine (e.g. "meghsh/php-base:"). Empty = official php:-fpm-alpine.' + required: false + default: '' + tag_suffix: + description: 'Tag suffix for the pushed images (e.g. "-test"). Empty = release tags.' + required: false + default: '' + openlitespeed: + description: 'Build OpenLiteSpeed images' + type: boolean + required: false + default: true schedule: - cron: '0 0 1,15 * *' push: @@ -10,27 +28,66 @@ on: tags: - '*' +# Do not cancel: a push to main must not cancel a release run. Runs on the same ref wait in a queue. concurrency: group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: false env: DOCKER_ORG_USERNAME: ${{ vars.DOCKER_ORG_USERNAME || 'meghsh' }} + TAG_SUFFIX: ${{ inputs.tag_suffix }} jobs: - build-php: + setup: runs-on: ubuntu-latest + outputs: + php: ${{ steps.matrix.outputs.php }} + openlitespeed: ${{ steps.matrix.outputs.openlitespeed }} + steps: + - name: Create matrix + id: matrix + env: + PHP_VERSIONS: ${{ inputs.php_versions }} + BUILD_OPENLITESPEED: ${{ github.event_name != 'workflow_dispatch' || inputs.openlitespeed }} + run: | + all='["7.4","8.0","8.1","8.2","8.3","8.4","8.5"]' + ols_all='[{"php_version":"7.4","lsphp":"lsphp74"},{"php_version":"8.1","lsphp":"lsphp81"},{"php_version":"8.2","lsphp":"lsphp82"},{"php_version":"8.3","lsphp":"lsphp83"},{"php_version":"8.4","lsphp":"lsphp84"}]' + php=$(jq -nc --arg versions "$PHP_VERSIONS" --argjson all "$all" \ + '$versions | gsub(","; " ") | split(" ") | map(select(length > 0)) | if length == 0 then $all else . end') + jq -ne --argjson php "$php" --argjson all "$all" '$php - $all | length == 0' > /dev/null \ + || { echo "::error::Unknown PHP version in: $PHP_VERSIONS"; exit 1; } + if [ "$BUILD_OPENLITESPEED" = "true" ]; then + ols=$(jq -c --argjson php "$php" 'map(select(.php_version as $v | $php | index($v)))' <<< "$ols_all") + else + ols='[]' + fi + echo "php=$php" >> "$GITHUB_OUTPUT" + echo "openlitespeed=$ols" >> "$GITHUB_OUTPUT" + echo "PHP: $php" + echo "OpenLiteSpeed: $ols" + + build-php: + needs: setup + runs-on: ${{ matrix.runner }} strategy: fail-fast: false matrix: - php_version: ['7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5'] + php_version: ${{ fromJSON(needs.setup.outputs.php) }} + platform: [linux/amd64, linux/arm64] + include: + - platform: linux/amd64 + arch: amd64 + runner: ubuntu-latest + - platform: linux/arm64 + arch: arm64 + runner: ubuntu-24.04-arm + env: + IMAGE: ${{ vars.DOCKER_ORG_USERNAME || 'meghsh' }}/php + BASE_IMAGE: ${{ inputs.base_image && format('{0}{1}-fpm-alpine', inputs.base_image, matrix.php_version) || format('php:{0}-fpm-alpine', matrix.php_version) }} steps: - name: Checkout uses: actions/checkout@v4 - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -40,19 +97,23 @@ jobs: file: php/Dockerfile context: php load: true - tags: ${{ env.DOCKER_ORG_USERNAME }}/php:${{ matrix.php_version }} + tags: ${{ env.IMAGE }}:${{ matrix.php_version }}${{ env.TAG_SUFFIX }} build-args: | PHP_VERSION=${{ matrix.php_version }} + BASE_IMAGE=${{ env.BASE_IMAGE }} + + - name: Show PHP version + run: docker run --rm --entrypoint php "${IMAGE}:${{ matrix.php_version }}${TAG_SUFFIX}" -v - name: Install Container Structure Test run: | - curl -LO https://storage.googleapis.com/container-structure-test/latest/container-structure-test-linux-amd64 - chmod +x container-structure-test-linux-amd64 - sudo mv container-structure-test-linux-amd64 /usr/local/bin/container-structure-test + curl -fsSLo container-structure-test https://storage.googleapis.com/container-structure-test/latest/container-structure-test-linux-${{ matrix.arch }} + chmod +x container-structure-test + sudo mv container-structure-test /usr/local/bin/container-structure-test - name: Run Container Structure Tests run: | - container-structure-test test --image ${{ env.DOCKER_ORG_USERNAME }}/php:${{ matrix.php_version }} --config php-structure-test.yaml + container-structure-test test --image "${IMAGE}:${{ matrix.php_version }}${TAG_SUFFIX}" --config php-structure-test.yaml - name: Login to Docker Hub uses: docker/login-action@v3 @@ -60,43 +121,90 @@ jobs: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - - name: Push PHP Docker image - if: success() + - name: Push PHP Docker image by digest + id: push uses: docker/build-push-action@v5 with: file: php/Dockerfile context: php - platforms: linux/amd64,linux/arm64 - push: true - tags: ${{ env.DOCKER_ORG_USERNAME }}/php:${{ matrix.php_version }} + platforms: ${{ matrix.platform }} + outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true build-args: | PHP_VERSION=${{ matrix.php_version }} + BASE_IMAGE=${{ env.BASE_IMAGE }} - build-openlitespeed: + - name: Export digest + env: + DIGEST: ${{ steps.push.outputs.digest }} + run: | + mkdir -p /tmp/digests + touch "/tmp/digests/${DIGEST#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@v4 + with: + name: digests-php-${{ matrix.php_version }}-${{ matrix.arch }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + merge-php: + needs: [setup, build-php] + # Keep the other PHP versions going when one build fails. + if: ${{ !cancelled() }} runs-on: ubuntu-latest strategy: + fail-fast: false matrix: - php_version: ['7.4', '8.1', '8.2', '8.3', '8.4'] + php_version: ${{ fromJSON(needs.setup.outputs.php) }} + env: + IMAGE: ${{ vars.DOCKER_ORG_USERNAME || 'meghsh' }}/php + steps: + - name: Download digests + uses: actions/download-artifact@v4 + with: + path: /tmp/digests + pattern: digests-php-${{ matrix.php_version }}-* + merge-multiple: true + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Create manifest list and push + working-directory: /tmp/digests + run: | + [ "$(ls | wc -l)" -eq 2 ] || { echo "::error::Expected 2 digests (amd64, arm64), got $(ls | wc -l)"; exit 1; } + docker buildx imagetools create -t "${IMAGE}:${{ matrix.php_version }}${TAG_SUFFIX}" $(printf "${IMAGE}@sha256:%s " *) + docker buildx imagetools inspect "${IMAGE}:${{ matrix.php_version }}${TAG_SUFFIX}" + + build-openlitespeed: + needs: setup + if: ${{ needs.setup.outputs.openlitespeed != '[]' }} + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + ols: ${{ fromJSON(needs.setup.outputs.openlitespeed) }} + platform: [linux/amd64, linux/arm64] include: - - php_version: '7.4' - lsphp: 'lsphp74' - - php_version: '8.1' - lsphp: 'lsphp81' - - php_version: '8.2' - lsphp: 'lsphp82' - - php_version: '8.3' - lsphp: 'lsphp83' - - php_version: '8.4' - lsphp: 'lsphp84' + - platform: linux/amd64 + arch: amd64 + runner: ubuntu-latest + - platform: linux/arm64 + arch: arm64 + runner: ubuntu-24.04-arm + env: + IMAGE: ${{ vars.DOCKER_ORG_USERNAME || 'meghsh' }}/openlitespeed steps: - name: Checkout uses: actions/checkout@v4 - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - with: - image: tonistiigi/binfmt:qemu-v7.0.0-28 - - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -106,20 +214,20 @@ jobs: file: openlitespeed/Dockerfile context: openlitespeed load: true - tags: ${{ env.DOCKER_ORG_USERNAME }}/openlitespeed:${{ matrix.php_version }} + tags: ${{ env.IMAGE }}:${{ matrix.ols.php_version }}${{ env.TAG_SUFFIX }} build-args: | - PHP_VERSION=${{ matrix.php_version }} - LSPHP=${{ matrix.lsphp }} + PHP_VERSION=${{ matrix.ols.php_version }} + LSPHP=${{ matrix.ols.lsphp }} - name: Install Container Structure Test run: | - curl -LO https://storage.googleapis.com/container-structure-test/latest/container-structure-test-linux-amd64 - chmod +x container-structure-test-linux-amd64 - sudo mv container-structure-test-linux-amd64 /usr/local/bin/container-structure-test + curl -fsSLo container-structure-test https://storage.googleapis.com/container-structure-test/latest/container-structure-test-linux-${{ matrix.arch }} + chmod +x container-structure-test + sudo mv container-structure-test /usr/local/bin/container-structure-test - name: Run Container Structure Tests run: | - container-structure-test test --image ${{ env.DOCKER_ORG_USERNAME }}/openlitespeed:${{ matrix.php_version }} --config ols-structure-test.yaml + container-structure-test test --image "${IMAGE}:${{ matrix.ols.php_version }}${TAG_SUFFIX}" --config ols-structure-test.yaml - name: Login to Docker Hub uses: docker/login-action@v3 @@ -127,26 +235,79 @@ jobs: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - - name: Push OpenLiteSpeed Docker image - if: success() + - name: Push OpenLiteSpeed Docker image by digest + id: push uses: docker/build-push-action@v5 with: file: openlitespeed/Dockerfile context: openlitespeed - platforms: linux/amd64,linux/arm64 - push: true - tags: ${{ env.DOCKER_ORG_USERNAME }}/openlitespeed:${{ matrix.php_version }} + platforms: ${{ matrix.platform }} + outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true build-args: | - PHP_VERSION=${{ matrix.php_version }} - LSPHP=${{ matrix.lsphp }} + PHP_VERSION=${{ matrix.ols.php_version }} + LSPHP=${{ matrix.ols.lsphp }} + + - name: Export digest + env: + DIGEST: ${{ steps.push.outputs.digest }} + run: | + mkdir -p /tmp/digests + touch "/tmp/digests/${DIGEST#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@v4 + with: + name: digests-ols-${{ matrix.ols.php_version }}-${{ matrix.arch }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + merge-openlitespeed: + needs: [setup, build-openlitespeed] + if: ${{ !cancelled() && needs.setup.outputs.openlitespeed != '[]' }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + ols: ${{ fromJSON(needs.setup.outputs.openlitespeed) }} + env: + IMAGE: ${{ vars.DOCKER_ORG_USERNAME || 'meghsh' }}/openlitespeed + steps: + - name: Download digests + uses: actions/download-artifact@v4 + with: + path: /tmp/digests + pattern: digests-ols-${{ matrix.ols.php_version }}-* + merge-multiple: true + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Create manifest list and push + working-directory: /tmp/digests + run: | + [ "$(ls | wc -l)" -eq 2 ] || { echo "::error::Expected 2 digests (amd64, arm64), got $(ls | wc -l)"; exit 1; } + docker buildx imagetools create -t "${IMAGE}:${{ matrix.ols.php_version }}${TAG_SUFFIX}" $(printf "${IMAGE}@sha256:%s " *) + docker buildx imagetools inspect "${IMAGE}:${{ matrix.ols.php_version }}${TAG_SUFFIX}" build-builder: runs-on: ubuntu-latest - # Builder is FROM meghsh/php:, so the base image must be pushed first. - needs: build-php + # Builder is FROM meghsh/php:, so the PHP image must be pushed first. + needs: [setup, merge-php] + if: ${{ !cancelled() }} strategy: + fail-fast: false matrix: - php_version: ['7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5'] + php_version: ${{ fromJSON(needs.setup.outputs.php) }} + env: + IMAGE: ${{ vars.DOCKER_ORG_USERNAME || 'meghsh' }}/builder + PHP_IMAGE: ${{ vars.DOCKER_ORG_USERNAME || 'meghsh' }}/php:${{ matrix.php_version }}${{ inputs.tag_suffix }} steps: - name: Checkout uses: actions/checkout@v4 @@ -163,19 +324,20 @@ jobs: file: builder/Dockerfile context: builder load: true - tags: ${{ env.DOCKER_ORG_USERNAME }}/builder:${{ matrix.php_version }} + tags: ${{ env.IMAGE }}:${{ matrix.php_version }}${{ env.TAG_SUFFIX }} build-args: | PHP_VERSION=${{ matrix.php_version }} + PHP_IMAGE=${{ env.PHP_IMAGE }} - name: Install Container Structure Test run: | - curl -LO https://storage.googleapis.com/container-structure-test/latest/container-structure-test-linux-amd64 - chmod +x container-structure-test-linux-amd64 - sudo mv container-structure-test-linux-amd64 /usr/local/bin/container-structure-test + curl -fsSLo container-structure-test https://storage.googleapis.com/container-structure-test/latest/container-structure-test-linux-amd64 + chmod +x container-structure-test + sudo mv container-structure-test /usr/local/bin/container-structure-test - name: Run Container Structure Tests run: | - container-structure-test test --image ${{ env.DOCKER_ORG_USERNAME }}/builder:${{ matrix.php_version }} --config builder-structure-test.yaml + container-structure-test test --image "${IMAGE}:${{ matrix.php_version }}${TAG_SUFFIX}" --config builder-structure-test.yaml - name: Login to Docker Hub uses: docker/login-action@v3 @@ -191,6 +353,7 @@ jobs: context: builder platforms: linux/amd64,linux/arm64 push: true - tags: ${{ env.DOCKER_ORG_USERNAME }}/builder:${{ matrix.php_version }} + tags: ${{ env.IMAGE }}:${{ matrix.php_version }}${{ env.TAG_SUFFIX }} build-args: | PHP_VERSION=${{ matrix.php_version }} + PHP_IMAGE=${{ env.PHP_IMAGE }} diff --git a/builder/Dockerfile b/builder/Dockerfile index e74a5b7..aa3bbf7 100644 --- a/builder/Dockerfile +++ b/builder/Dockerfile @@ -9,7 +9,9 @@ ARG PHP_VERSION=8.3 # Because it is FROM meghsh/php:${PHP_VERSION}, the PHP version and the full set # of PHP extensions match runtime exactly, so `composer install` platform checks # pass without --ignore-platform-reqs. -FROM meghsh/php:${PHP_VERSION} +# Set PHP_IMAGE to build on a test tag of the PHP image. +ARG PHP_IMAGE=meghsh/php:${PHP_VERSION} +FROM ${PHP_IMAGE} LABEL maintainer="Tareq Hasan " diff --git a/ols-structure-test.yaml b/ols-structure-test.yaml index 311f301..0cd25b0 100644 --- a/ols-structure-test.yaml +++ b/ols-structure-test.yaml @@ -60,3 +60,8 @@ commandTests: command: "git" args: ["--version"] expectedOutput: ["git version"] + + - name: "Session upload progress is disabled (CVE-2026-109619)" + command: "php" + args: ["-r", "echo ini_get('session.upload_progress.enabled') ? 'upload_progress=on' : 'upload_progress=off';"] + expectedOutput: ["upload_progress=off"] diff --git a/openlitespeed/Dockerfile b/openlitespeed/Dockerfile index 2cda186..305fef9 100644 --- a/openlitespeed/Dockerfile +++ b/openlitespeed/Dockerfile @@ -69,6 +69,7 @@ RUN echo "sendmail_path = /usr/bin/msmtp -t" >> /usr/local/lsws/${LSPHP}/etc/php WORKDIR /var/www/html # Copy configuration files +COPY security.ini /usr/local/lsws/${LSPHP}/etc/php/${PHP_VERSION}/mods-available/zz-security.ini COPY httpd_config.conf /usr/local/lsws/conf/httpd_config.conf COPY vhconf.conf /usr/local/lsws/conf/vhosts/flywp/vhconf.conf diff --git a/openlitespeed/security.ini b/openlitespeed/security.ini new file mode 100644 index 0000000..62e2fec --- /dev/null +++ b/openlitespeed/security.ini @@ -0,0 +1,3 @@ +; CVE-2026-109619 (GHSA-6wmf-4rv5-p3rp): upload progress is a confirmed exploit path. +; PHP advises to keep it disabled even after the patch when the feature is unused. +session.upload_progress.enabled = Off diff --git a/php-structure-test.yaml b/php-structure-test.yaml index 19e9e56..0498b04 100644 --- a/php-structure-test.yaml +++ b/php-structure-test.yaml @@ -104,3 +104,13 @@ commandTests: command: "convert" args: ["-version"] expectedOutput: ["ImageMagick"] + + - name: "Session upload progress is disabled (CVE-2026-109619)" + command: "php" + args: ["-r", "echo ini_get('session.upload_progress.enabled') ? 'upload_progress=on' : 'upload_progress=off';"] + expectedOutput: ["upload_progress=off"] + +fileContentTests: + - name: "FPM pool locks session upload progress off" + path: "/usr/local/etc/php-fpm.d/zz-security.conf" + expectedContents: ["php_admin_flag\\[session.upload_progress.enabled\\] = off"] diff --git a/php/Dockerfile b/php/Dockerfile index 7de9c87..6517aa2 100644 --- a/php/Dockerfile +++ b/php/Dockerfile @@ -1,4 +1,6 @@ ARG PHP_VERSION=7.4 +# Base for the builder and final stages. Set it to use an image built by build-base.yml. +ARG BASE_IMAGE=php:${PHP_VERSION}-fpm-alpine # Stage for Composer FROM php:${PHP_VERSION}-cli-alpine AS composer_builder @@ -23,7 +25,7 @@ RUN apk add --no-cache curl unzip \ && rm -rf /var/cache/apk/* # Main build stage -FROM php:${PHP_VERSION}-fpm-alpine AS builder +FROM ${BASE_IMAGE} AS builder LABEL maintainer="Tareq Hasan " @@ -47,11 +49,14 @@ RUN apk add --no-cache ca-certificates wget gnupg && \ apk add --allow-untrusted msodbcsql18_18.6.1.1-1_${ARCH}.apk mssql-tools18_18.6.1.1-1_${ARCH}.apk && \ rm -f msodbcsql18_18.6.1.1-1_${ARCH}.apk msodbcsql18_18.6.1.1-1_${ARCH}.sig mssql-tools18_18.6.1.1-1_${ARCH}.apk mssql-tools18_18.6.1.1-1_${ARCH}.sig +# Retry wrapper for network installs. In /usr/local/sbin, so the final stage does not copy it. +COPY retry.sh /usr/local/sbin/retry + # Install PHP extensions using php-extension-installer RUN apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS \ && curl -L -o /usr/local/bin/install-php-extensions https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions \ && chmod +x /usr/local/bin/install-php-extensions \ - && install-php-extensions bcmath curl dom exif fileinfo gd hash igbinary intl json mbstring mysqli opcache openssl pcre pdo_mysql pdo_sqlite redis shmop simplexml sodium soap sockets ssh2 tokenizer xml xmlreader xmlwriter zip zlib apcu ftp pspell pcntl mongodb ldap imap gmp gettext excimer ev calendar xsl sqlsrv pdo_sqlsrv pdo_pgsql + && retry install-php-extensions bcmath curl dom exif fileinfo gd hash igbinary intl json mbstring mysqli opcache openssl pcre pdo_mysql pdo_sqlite redis shmop simplexml sodium soap sockets ssh2 tokenizer xml xmlreader xmlwriter zip zlib apcu ftp pspell pcntl mongodb ldap imap gmp gettext excimer ev calendar xsl sqlsrv pdo_sqlsrv pdo_pgsql # Install imagick manually for PHP 8.3 RUN if [[ "$PHP_VERSION" == "8.3"* ]]; then \ @@ -63,14 +68,14 @@ RUN if [[ "$PHP_VERSION" == "8.3"* ]]; then \ make install && \ docker-php-ext-enable imagick; \ else \ - install-php-extensions imagick; \ + retry install-php-extensions imagick; \ fi # Install relay last; it can't load under QEMU arm64 emulation, so skip its check there RUN if [ "$(uname -m)" = "aarch64" ]; then \ - IPE_SKIP_CHECK=1 install-php-extensions relay; \ + IPE_SKIP_CHECK=1 retry install-php-extensions relay; \ else \ - install-php-extensions relay; \ + retry install-php-extensions relay; \ fi # Clean up build dependencies @@ -79,6 +84,7 @@ RUN apk del .build-deps .phpize-deps \ # Set recommended PHP.ini settings and configure error logging COPY error-logging.ini expose.ini /usr/local/etc/php/conf.d/ +COPY security.ini /usr/local/etc/php/conf.d/zz-security.ini # Configure sendmail path for MSMTP RUN echo "sendmail_path = '/usr/bin/msmtp -t'" > /usr/local/etc/php/conf.d/mail.ini @@ -89,7 +95,7 @@ COPY --from=wpcli_builder /usr/local/bin/wp /usr/local/bin/wp COPY --from=wpcli_builder /home/www-data/.wp-cli /home/www-data/.wp-cli # Final stage -FROM php:${PHP_VERSION}-fpm-alpine +FROM ${BASE_IMAGE} # Install runtime dependencies (including libraries for PHP extensions) RUN apk add --no-cache \ @@ -101,6 +107,7 @@ RUN apk add --no-cache \ # Copy built extensions and configs from the builder stage COPY --from=builder /usr/local/etc/php /usr/local/etc/php +COPY zz-security.conf /usr/local/etc/php-fpm.d/zz-security.conf COPY --from=builder /usr/local/lib/php/extensions /usr/local/lib/php/extensions COPY --from=builder /usr/local/bin /usr/local/bin COPY --from=builder /home/www-data/.wp-cli /home/www-data/.wp-cli diff --git a/php/retry.sh b/php/retry.sh new file mode 100755 index 0000000..fc3a15c --- /dev/null +++ b/php/retry.sh @@ -0,0 +1,9 @@ +#!/bin/sh +# Run a command up to 5 times, 30 seconds apart. +# pecl.php.net sometimes returns 504 or "No releases available". +for attempt in 1 2 3 4 5; do + "$@" && exit 0 + echo "Attempt $attempt of 5 failed: $*" >&2 + [ "$attempt" -lt 5 ] && sleep 30 +done +exit 1 diff --git a/php/security.ini b/php/security.ini new file mode 100644 index 0000000..62e2fec --- /dev/null +++ b/php/security.ini @@ -0,0 +1,3 @@ +; CVE-2026-109619 (GHSA-6wmf-4rv5-p3rp): upload progress is a confirmed exploit path. +; PHP advises to keep it disabled even after the patch when the feature is unused. +session.upload_progress.enabled = Off diff --git a/php/zz-security.conf b/php/zz-security.conf new file mode 100644 index 0000000..afa7b42 --- /dev/null +++ b/php/zz-security.conf @@ -0,0 +1,3 @@ +; Set as admin flag so a site .user.ini cannot enable it again (CVE-2026-109619). +[www] +php_admin_flag[session.upload_progress.enabled] = off