From 46719df0d23d2e3c4d4242e5df6b7a5a9a97761c Mon Sep 17 00:00:00 2001 From: KingPin <28669+KingPin@users.noreply.github.com> Date: Sun, 17 May 2026 23:47:13 -0400 Subject: [PATCH 01/10] build: drop redundant json from install-php-extensions json is bundled in PHP since 8.0 and cannot be disabled, so passing it to install-php-extensions is a no-op. Removing it slightly speeds the extension install step and avoids implying it's optional. --- Dockerfile.v1 | 2 +- Dockerfile.v2 | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile.v1 b/Dockerfile.v1 index c1fa958..dba4cad 100644 --- a/Dockerfile.v1 +++ b/Dockerfile.v1 @@ -27,7 +27,7 @@ RUN retry 3 curl -sSLf -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 && \ retry 3 install-php-extensions \ - amqp bcmath bz2 calendar ctype exif intl imagick imap json mbstring ldap mcrypt memcached mongodb \ + amqp bcmath bz2 calendar ctype exif intl imagick imap mbstring ldap mcrypt memcached mongodb \ mysqli opcache pdo_mysql pdo_pgsql pgsql redis snmp soap sockets tidy timezonedb uuid vips xsl yaml zip zstd @composer # Enable Apache rewrite mod, if applicable diff --git a/Dockerfile.v2 b/Dockerfile.v2 index 2d7fb79..3b0bc49 100644 --- a/Dockerfile.v2 +++ b/Dockerfile.v2 @@ -139,7 +139,7 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked,id=apt-$BASEOS \ chmod +x /usr/local/bin/install-php-extensions && \ # Install PHP extensions install-php-extensions \ - json mysqli pdo_mysql pdo_pgsql pgsql soap sockets \ + mysqli pdo_mysql pdo_pgsql pgsql soap sockets \ opcache redis memcached zstd \ zip bz2 \ amqp bcmath calendar ctype exif intl imagick imap ldap mbstring mcrypt \ From 5d7fb7c5ef6cbc896b2166368236ca2baca54473 Mon Sep 17 00:00:00 2001 From: KingPin <28669+KingPin@users.noreply.github.com> Date: Sun, 17 May 2026 23:48:26 -0400 Subject: [PATCH 02/10] build: use COPY --chmod and .gitattributes for line endings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the separate `RUN chmod +x` layers and the `find … sed -i 's/\r$//'` build-time line-ending fix with two cleaner alternatives: - `COPY --chmod=0755` sets the executable bit at copy time, removing a whole image layer per script. - `.gitattributes` pins LF for shell scripts, s6-overlay files, and Dockerfiles so CRLF can't sneak in via a Windows checkout in the first place. s6's supervision tree refuses CRLF; pinning at the VCS layer is more reliable than scrubbing during build. --- .gitattributes | 10 ++++++++++ Dockerfile.v1 | 3 +-- Dockerfile.v2 | 13 ++++--------- 3 files changed, 15 insertions(+), 11 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..b53433c --- /dev/null +++ b/.gitattributes @@ -0,0 +1,10 @@ +* text=auto eol=lf + +# Shell scripts and s6-overlay service files must always use LF line +# endings — the s6 supervision tree refuses to exec scripts with CRLF. +*.sh text eol=lf +extras/retry.sh text eol=lf +s6-overlay/** text eol=lf + +# Dockerfiles +Dockerfile* text eol=lf diff --git a/Dockerfile.v1 b/Dockerfile.v1 index dba4cad..07e0d22 100644 --- a/Dockerfile.v1 +++ b/Dockerfile.v1 @@ -6,8 +6,7 @@ ARG BASEOS # Set environment variables ENV DEBIAN_FRONTEND=noninteractive -COPY extras/retry.sh /usr/local/bin/retry -RUN chmod +x /usr/local/bin/retry +COPY --chmod=0755 extras/retry.sh /usr/local/bin/retry # Install dependencies based on the base OS RUN --mount=type=cache,target=/var/cache/apt,sharing=locked,id=apt-$BASEOS \ diff --git a/Dockerfile.v2 b/Dockerfile.v2 index 3b0bc49..b052572 100644 --- a/Dockerfile.v2 +++ b/Dockerfile.v2 @@ -12,8 +12,7 @@ ARG VERSION # Set environment variables ENV DEBIAN_FRONTEND=noninteractive -COPY extras/retry.sh /usr/local/bin/retry -RUN chmod +x /usr/local/bin/retry +COPY --chmod=0755 extras/retry.sh /usr/local/bin/retry # OCI standard labels LABEL org.opencontainers.image.title="php-docker" \ @@ -264,13 +263,9 @@ RUN if [ "$BASEOS" != "alpine" ]; then \ WORKDIR /var/www/html -# Copy S6 configuration files -COPY s6-overlay/cont-init.d/ /etc/cont-init.d/ -COPY s6-overlay/services.d/ /etc/services.d/ - -# Set permissions for S6 scripts and convert line endings -RUN chmod -R 755 /etc/cont-init.d /etc/services.d && \ - find /etc/cont-init.d /etc/services.d -type f -exec sed -i 's/\r$//' {} + +# Copy S6 configuration files (line endings enforced via .gitattributes) +COPY --chmod=0755 s6-overlay/cont-init.d/ /etc/cont-init.d/ +COPY --chmod=0755 s6-overlay/services.d/ /etc/services.d/ ENTRYPOINT ["/init"] CMD [] From 548abc626dc09a54b528d6a4834230e3563447d8 Mon Sep 17 00:00:00 2001 From: KingPin <28669+KingPin@users.noreply.github.com> Date: Sun, 17 May 2026 23:48:47 -0400 Subject: [PATCH 03/10] docs(dockerfile): explain Trixie t64 equivs workaround Drop a comment block above the libmemcachedutil2 / libssl3 dummy-package synthesis explaining what the t64 transition is and the specific condition under which this hack can be removed: when upstream install-php-extensions binaries link against the t64 SONAMEs directly. This is the most opaque piece of Dockerfile.v2 and the one most likely to bite a future maintainer. --- Dockerfile.v2 | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Dockerfile.v2 b/Dockerfile.v2 index b052572..b852dfc 100644 --- a/Dockerfile.v2 +++ b/Dockerfile.v2 @@ -65,6 +65,18 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked,id=apt-$BASEOS \ libxpm4 \ libxpm-dev \ equivs && \ + # --------------------------------------------------------------- + # Debian Trixie t64 transition (time_t → 64-bit on 32-bit archs) + # renamed several library packages to the *t64 suffix. Some + # pre-built artifacts from install-php-extensions still declare + # runtime deps under the *old* names (libmemcachedutil2, libssl3) + # rather than the t64 variants (libmemcachedutil2t64, libssl3t64). + # We synthesize empty dummy packages with `equivs` that satisfy + # those old names while actually pulling in the t64 packages, so + # apt's dep resolver is happy. Remove this block once upstream + # install-php-extensions (mlocati/docker-php-extension-installer) + # ships binaries linked against the t64 SONAMEs directly. + # --------------------------------------------------------------- echo 'Package: libmemcachedutil2' > /tmp/libmemcachedutil2.control && \ echo 'Version: 999.0' >> /tmp/libmemcachedutil2.control && \ echo 'Architecture: all' >> /tmp/libmemcachedutil2.control && \ From d022e6a84ff7d4d79c0340083be0edc4ae8ac7c3 Mon Sep 17 00:00:00 2001 From: KingPin <28669+KingPin@users.noreply.github.com> Date: Mon, 18 May 2026 00:07:12 -0400 Subject: [PATCH 04/10] ci: generate publish matrix from setup job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The publish job previously declared its own inline matrix (variant × php-version × php-type × php-base, with the same exclude rules and a hand-rolled list of 12 v2/trixie includes). It duplicated the logic already in the setup job and made the two trivially easy to drift. Have setup emit both a test_matrix (narrowed on PRs) and a publish_matrix (always the full set) from a single `build_matrix` shell function. Both jobs consume their respective output. Net: -45 lines, one source of truth, adding a php-version requires one edit instead of two. Also moves the github.event_name interpolation into an env: block per workflow-injection guidance. --- .github/workflows/docker-ci.yml | 131 +++++++++++--------------------- 1 file changed, 43 insertions(+), 88 deletions(-) diff --git a/.github/workflows/docker-ci.yml b/.github/workflows/docker-ci.yml index 94878ea..9790bcc 100644 --- a/.github/workflows/docker-ci.yml +++ b/.github/workflows/docker-ci.yml @@ -33,43 +33,58 @@ jobs: setup: runs-on: ubuntu-latest outputs: - matrix: ${{ steps.matrix.outputs.matrix }} + test_matrix: ${{ steps.matrix.outputs.test_matrix }} + publish_matrix: ${{ steps.matrix.outputs.publish_matrix }} s6_version: ${{ steps.s6.outputs.version }} steps: - - name: Compute build matrix + - name: Compute build matrices id: matrix + env: + EVENT_NAME: ${{ github.event_name }} run: | - if [ "${{ github.event_name }}" = "pull_request" ]; then - PHP_VERSIONS='["8.5","8.2"]' + # The publish matrix is always the full set; the test matrix + # narrows the php-version axis on PRs so feedback stays fast. + FULL_VERSIONS='["8.5","8.4","8.3","8.2"]' + if [ "$EVENT_NAME" = "pull_request" ]; then + TEST_VERSIONS='["8.5","8.2"]' echo "::notice::PR detected — testing PHP 8.5 + 8.2 only (skipping 8.4 and 8.3)" else - PHP_VERSIONS='["8.5","8.4","8.3","8.2"]' + TEST_VERSIONS="$FULL_VERSIONS" fi - # Build trixie include list for v2 based on selected PHP versions - INCLUDES="[]" - for ver in $(echo "$PHP_VERSIONS" | jq -r '.[]'); do - for type in fpm cli apache; do - INCLUDES=$(echo "$INCLUDES" | jq -c ". + [{\"variant\":\"v2\",\"php-version\":\"$ver\",\"php-type\":\"$type\",\"php-base\":\"trixie\"}]") + # build_matrix VERSIONS_JSON → matrix JSON string. variant × php-version + # × php-type × php-base, minus apache-on-alpine and v2-on-bookworm, + # plus an explicit v2/trixie include list (one per type). + build_matrix() { + local versions="$1" + local includes="[]" + for ver in $(echo "$versions" | jq -r '.[]'); do + for type in fpm cli apache; do + includes=$(echo "$includes" | jq -c \ + ". + [{\"variant\":\"v2\",\"php-version\":\"$ver\",\"php-type\":\"$type\",\"php-base\":\"trixie\"}]") + done done - done + jq -n -c \ + --argjson versions "$versions" \ + --argjson includes "$includes" \ + '{ + "variant": ["v1","v2"], + "php-version": $versions, + "php-type": ["fpm","cli","apache"], + "php-base": ["alpine","bookworm"], + "exclude": [ + {"php-type":"apache","php-base":"alpine"}, + {"variant":"v2","php-base":"bookworm"} + ], + "include": $includes + }' + } - MATRIX=$(jq -n -c \ - --argjson versions "$PHP_VERSIONS" \ - --argjson includes "$INCLUDES" \ - '{ - "variant": ["v1","v2"], - "php-version": $versions, - "php-type": ["fpm","cli","apache"], - "php-base": ["alpine","bookworm"], - "exclude": [ - {"php-type":"apache","php-base":"alpine"}, - {"variant":"v2","php-base":"bookworm"} - ], - "include": $includes - }') + TEST_MATRIX=$(build_matrix "$TEST_VERSIONS") + PUBLISH_MATRIX=$(build_matrix "$FULL_VERSIONS") - echo "matrix=$MATRIX" >> $GITHUB_OUTPUT + echo "test_matrix=$TEST_MATRIX" >> $GITHUB_OUTPUT + echo "publish_matrix=$PUBLISH_MATRIX" >> $GITHUB_OUTPUT - name: Get latest s6-overlay version id: s6 @@ -91,7 +106,7 @@ jobs: runs-on: ubuntu-latest strategy: fail-fast: false - matrix: ${{ fromJson(needs.setup.outputs.matrix) }} + matrix: ${{ fromJson(needs.setup.outputs.test_matrix) }} name: ${{ matrix.variant }}-${{ matrix.php-version }}-${{ matrix.php-type }}-${{ matrix.php-base }} @@ -299,67 +314,7 @@ jobs: runs-on: ubuntu-latest strategy: fail-fast: false - matrix: - variant: [v1, v2] - php-version: ['8.5', '8.4', '8.3', '8.2'] - php-type: [fpm, cli, apache] - php-base: [alpine, bookworm] - exclude: - - php-type: apache - php-base: alpine - # v2 uses trixie as the Debian base; bookworm retained for v1 - - variant: v2 - php-base: bookworm - include: - # v2 builds on trixie for Debian images - - variant: v2 - php-version: '8.5' - php-type: fpm - php-base: trixie - - variant: v2 - php-version: '8.5' - php-type: cli - php-base: trixie - - variant: v2 - php-version: '8.5' - php-type: apache - php-base: trixie - - variant: v2 - php-version: '8.4' - php-type: fpm - php-base: trixie - - variant: v2 - php-version: '8.4' - php-type: cli - php-base: trixie - - variant: v2 - php-version: '8.4' - php-type: apache - php-base: trixie - - variant: v2 - php-version: '8.3' - php-type: fpm - php-base: trixie - - variant: v2 - php-version: '8.3' - php-type: cli - php-base: trixie - - variant: v2 - php-version: '8.3' - php-type: apache - php-base: trixie - - variant: v2 - php-version: '8.2' - php-type: fpm - php-base: trixie - - variant: v2 - php-version: '8.2' - php-type: cli - php-base: trixie - - variant: v2 - php-version: '8.2' - php-type: apache - php-base: trixie + matrix: ${{ fromJson(needs.setup.outputs.publish_matrix) }} name: publish-${{ matrix.variant }}-${{ matrix.php-version }}-${{ matrix.php-type }}-${{ matrix.php-base }} From ed588a04661e4c453b03ff32534201b89606e97a Mon Sep 17 00:00:00 2001 From: KingPin <28669+KingPin@users.noreply.github.com> Date: Mon, 18 May 2026 00:07:31 -0400 Subject: [PATCH 05/10] ci: attach SBOM to published images Enable sbom: true on the build-push-action so each pushed manifest gets a Software Bill of Materials attestation alongside the existing provenance attestation (mode=max). Supports supply-chain auditing of which OS packages / PHP extensions shipped in a given tag. --- .github/workflows/docker-ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/docker-ci.yml b/.github/workflows/docker-ci.yml index 9790bcc..aa23ba2 100644 --- a/.github/workflows/docker-ci.yml +++ b/.github/workflows/docker-ci.yml @@ -382,6 +382,7 @@ jobs: platforms: linux/amd64,linux/arm64,linux/arm/v7 push: true provenance: mode=max + sbom: true cache-from: type=gha,scope=${{ steps.vars.outputs.CACHE_SCOPE }} cache-to: type=gha,mode=max,scope=${{ steps.vars.outputs.CACHE_SCOPE }} build-args: | From 9cdd02e31402fb290b07547d98db22aa19a88aa9 Mon Sep 17 00:00:00 2001 From: KingPin <28669+KingPin@users.noreply.github.com> Date: Mon, 18 May 2026 00:11:37 -0400 Subject: [PATCH 06/10] ci: inline bookworm-compat tags in build-push tags list For v2/trixie builds the workflow previously created bookworm-aliased manifest tags in a follow-up `docker buildx imagetools create` step. Compute those alias tags up-front in the vars step and pass them as additional entries in the build-push-action `tags:` input instead. Benefits: - One less workflow step and three fewer registry API roundtrips per v2/trixie image (the manifest is published with all tags directly). - Bookworm-aliased tags now inherit the same provenance + SBOM attestations attached by build-push-action; the imagetools-create variant didn't carry them over. - Move matrix interpolations out of run: shell into env: per the workflow-injection hardening guidance. --- .github/workflows/docker-ci.yml | 75 +++++++++++++++++---------------- 1 file changed, 38 insertions(+), 37 deletions(-) diff --git a/.github/workflows/docker-ci.yml b/.github/workflows/docker-ci.yml index aa23ba2..22694cc 100644 --- a/.github/workflows/docker-ci.yml +++ b/.github/workflows/docker-ci.yml @@ -352,27 +352,55 @@ jobs: - name: Set publish variables id: vars + env: + PHP_VERSION: ${{ matrix.php-version }} + PHP_TYPE: ${{ matrix.php-type }} + PHP_BASE: ${{ matrix.php-base }} + VARIANT: ${{ matrix.variant }} + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} run: | - VERSION="${{ matrix.php-version }}-${{ matrix.php-type }}-${{ matrix.php-base }}" - - if [ "${{ matrix.variant }}" = "v2" ]; then + VERSION="${PHP_VERSION}-${PHP_TYPE}-${PHP_BASE}" + + if [ "$VARIANT" = "v2" ]; then TAG_SUFFIX="-v2" DOCKERFILE="Dockerfile.v2" else TAG_SUFFIX="" DOCKERFILE="Dockerfile.v1" fi - + BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ") - + + DOCKERHUB_TAG="docker.io/${DOCKERHUB_USERNAME}/php-docker:${VERSION}${TAG_SUFFIX}" + GHCR_TAG="ghcr.io/kingpin/php-docker:${VERSION}${TAG_SUFFIX}" + QUAY_TAG="quay.io/kingpinx1/php-docker:${VERSION}${TAG_SUFFIX}" + echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT echo "TAG_SUFFIX=${TAG_SUFFIX}" >> $GITHUB_OUTPUT echo "DOCKERFILE=${DOCKERFILE}" >> $GITHUB_OUTPUT echo "BUILD_DATE=${BUILD_DATE}" >> $GITHUB_OUTPUT - echo "DOCKERHUB_TAG=docker.io/${{ secrets.DOCKERHUB_USERNAME }}/php-docker:${VERSION}${TAG_SUFFIX}" >> $GITHUB_OUTPUT - echo "GHCR_TAG=ghcr.io/kingpin/php-docker:${VERSION}${TAG_SUFFIX}" >> $GITHUB_OUTPUT - echo "QUAY_TAG=quay.io/kingpinx1/php-docker:${VERSION}${TAG_SUFFIX}" >> $GITHUB_OUTPUT - echo "CACHE_SCOPE=${{ matrix.variant }}-${{ matrix.php-version }}-${{ matrix.php-type }}-${{ matrix.php-base }}" >> $GITHUB_OUTPUT + echo "DOCKERHUB_TAG=${DOCKERHUB_TAG}" >> $GITHUB_OUTPUT + echo "GHCR_TAG=${GHCR_TAG}" >> $GITHUB_OUTPUT + echo "QUAY_TAG=${QUAY_TAG}" >> $GITHUB_OUTPUT + echo "CACHE_SCOPE=${VARIANT}-${VERSION}" >> $GITHUB_OUTPUT + + # Build the multi-line TAGS output for build-push-action. Always + # includes the primary three. For v2/trixie builds, also alias to + # the bookworm tag so existing consumers don't break — formerly + # done in a separate `docker buildx imagetools create` step. + { + echo "TAGS<> $GITHUB_OUTPUT - name: Build and push multi-arch image uses: docker/build-push-action@v7 @@ -392,10 +420,7 @@ jobs: S6_OVERLAY_VERSION=${{ needs.setup.outputs.s6_version }} BUILD_DATE=${{ steps.vars.outputs.BUILD_DATE }} VCS_REF=${{ github.sha }} - tags: | - ${{ steps.vars.outputs.DOCKERHUB_TAG }} - ${{ steps.vars.outputs.GHCR_TAG }} - ${{ steps.vars.outputs.QUAY_TAG }} + tags: ${{ steps.vars.outputs.TAGS }} labels: | com.sumguy.php-docker.php.variant=${{ matrix.php-type }} com.sumguy.php-docker.image.variant=${{ matrix.variant }} @@ -403,30 +428,6 @@ jobs: com.sumguy.php-docker.build_url=${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} com.sumguy.php-docker.built_by=github-actions/docker-ci - - name: Create bookworm compatibility tag for v2 trixie images - if: matrix.variant == 'v2' && matrix.php-base == 'trixie' - run: | - echo "::group::Creating bookworm compatibility tags for trixie-built v2 image" - - # Replace 'trixie' with 'bookworm' in tag names to maintain backward compatibility - BOOKWORM_VERSION="${{ matrix.php-version }}-${{ matrix.php-type }}-bookworm" - - # Create manifest aliases pointing trixie-built images to bookworm tags - docker buildx imagetools create -t \ - docker.io/${{ secrets.DOCKERHUB_USERNAME }}/php-docker:${BOOKWORM_VERSION}-v2 \ - ${{ steps.vars.outputs.DOCKERHUB_TAG }} - - docker buildx imagetools create -t \ - ghcr.io/kingpin/php-docker:${BOOKWORM_VERSION}-v2 \ - ${{ steps.vars.outputs.GHCR_TAG }} - - docker buildx imagetools create -t \ - quay.io/kingpinx1/php-docker:${BOOKWORM_VERSION}-v2 \ - ${{ steps.vars.outputs.QUAY_TAG }} - - echo "✅ Created bookworm compatibility tags pointing to trixie image" - echo "::endgroup::" - - name: Run Trivy vulnerability scanner uses: aquasecurity/trivy-action@master with: From 07dee3fd0c7977008687807f7aa7ec258418917b Mon Sep 17 00:00:00 2001 From: KingPin <28669+KingPin@users.noreply.github.com> Date: Mon, 18 May 2026 00:13:58 -0400 Subject: [PATCH 07/10] ci: run Trivy in build-and-test to gate publish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously Trivy ran in the publish job *after* the image had already been pushed to all three registries — a CRITICAL finding would ship to users before anyone saw it, and the SARIF upload told us about it after the fact. Move the scan into build-and-test against the locally-loaded amd64 test image and set exit-code: 1 so a CRITICAL or HIGH finding fails the job. publish depends on build-and-test, so vulnerable images can no longer reach the registries. Notes: - ignore-unfixed: true suppresses advisories without an upstream fix (otherwise we'd be permanently red on every base-image release). - security-events: write added to build-and-test so the SARIF upload to GitHub Code Scanning still works. - Upload step runs `if: always()` so scan results land in Security even when the gate fails — that's the whole point of the report. --- .github/workflows/docker-ci.yml | 37 +++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/.github/workflows/docker-ci.yml b/.github/workflows/docker-ci.yml index 22694cc..e0f0f87 100644 --- a/.github/workflows/docker-ci.yml +++ b/.github/workflows/docker-ci.yml @@ -104,6 +104,9 @@ jobs: build-and-test: needs: setup runs-on: ubuntu-latest + permissions: + contents: read + security-events: write strategy: fail-fast: false matrix: ${{ fromJson(needs.setup.outputs.test_matrix) }} @@ -304,6 +307,27 @@ jobs: echo "✅ Apache version check passed" echo "::endgroup::" + # Scan the locally-loaded test image (amd64) before publish gets a + # chance to run. exit-code: 1 turns CRITICAL/HIGH findings into a job + # failure, so vulnerable images never reach the registries. + # ignore-unfixed: true suppresses advisories with no upstream fix yet. + - name: Trivy vulnerability scan + uses: aquasecurity/trivy-action@master + with: + scan-type: image + image-ref: test-${{ steps.vars.outputs.TAG }} + format: 'sarif' + severity: 'CRITICAL,HIGH' + ignore-unfixed: true + exit-code: '1' + output: 'trivy-results-${{ matrix.variant }}-${{ matrix.php-version }}-${{ matrix.php-type }}-${{ matrix.php-base }}.sarif' + + - name: Upload Trivy results + if: always() + uses: github/codeql-action/upload-sarif@v4 + with: + sarif_file: 'trivy-results-${{ matrix.variant }}-${{ matrix.php-version }}-${{ matrix.php-type }}-${{ matrix.php-base }}.sarif' + - name: Summary run: | echo "::notice::✅ Build and tests passed for ${{ matrix.variant }} - ${{ steps.vars.outputs.TAG }}" @@ -428,16 +452,3 @@ jobs: com.sumguy.php-docker.build_url=${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} com.sumguy.php-docker.built_by=github-actions/docker-ci - - name: Run Trivy vulnerability scanner - uses: aquasecurity/trivy-action@master - with: - scan-type: image - image-ref: ${{ steps.vars.outputs.DOCKERHUB_TAG }} - format: 'sarif' - severity: 'CRITICAL,HIGH' - output: 'trivy-results-${{ matrix.variant }}-${{ matrix.php-version }}-${{ matrix.php-type }}-${{ matrix.php-base }}.sarif' - - - name: Upload Trivy results - uses: github/codeql-action/upload-sarif@v4 - with: - sarif_file: 'trivy-results-${{ matrix.variant }}-${{ matrix.php-version }}-${{ matrix.php-type }}-${{ matrix.php-base }}.sarif' From c13d148ea793671f31eb4d8cfdf18a35d85aeb65 Mon Sep 17 00:00:00 2001 From: KingPin <28669+KingPin@users.noreply.github.com> Date: Mon, 18 May 2026 00:22:53 -0400 Subject: [PATCH 08/10] ci: per-arch parallel builds with native arm64 runners MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the prior two-job pipeline (build-and-test amd64, then publish multi-arch from scratch via QEMU) with a per-arch build matrix that pushes by digest and a merge step that assembles final manifests. Changes: - setup now emits a build_matrix with an arch axis. On PRs only amd64 is built; on push/schedule/dispatch all three are built in parallel. Each entry carries its own runner/platform/qemu fields so the matrix selects the correct GitHub runner without YAML duplication. - arm64 builds run natively on `ubuntu-24.04-arm` instead of through QEMU on amd64 — typically 3–5× faster for a non-trivial extension compile workload. amd64 stays on ubuntu-latest. arm/v7 still uses QEMU since no free native armv7 runner exists. - amd64 jobs continue to load + smoke-test + Trivy-scan the image; on main they additionally push the same image by digest to the GHCR staging registry (cache-hit-driven second build of the same content). arm64 / arm/v7 jobs push directly by digest with no smoke tests. - publish-merge replaces the prior publish job. It downloads the per-arch digest artifacts for a given (variant, php-version, php-type, php-base) tuple and runs `docker buildx imagetools create` three times — once per registry — to assemble the manifest lists with the primary tag plus the v2/trixie→bookworm compatibility tag. Net wins: - Eliminates the redundant amd64 rebuild that publish used to do. - arm64 throughput dominated by native execution, not emulation. - Build-and-test feedback for PRs is unchanged: amd64 only. Hardening: - All `matrix.*` interpolations in run: scripts moved to env: blocks. - Cache scope is now per-arch so concurrent matrix jobs don't compete on the same gha cache key. - Trivy SARIF gets an explicit `category:` so per-tuple uploads don't overwrite each other in the GitHub Security tab. --- .github/workflows/docker-ci.yml | 433 +++++++++++++++++++------------- 1 file changed, 258 insertions(+), 175 deletions(-) diff --git a/.github/workflows/docker-ci.yml b/.github/workflows/docker-ci.yml index e0f0f87..bcc3ef5 100644 --- a/.github/workflows/docker-ci.yml +++ b/.github/workflows/docker-ci.yml @@ -33,7 +33,7 @@ jobs: setup: runs-on: ubuntu-latest outputs: - test_matrix: ${{ steps.matrix.outputs.test_matrix }} + build_matrix: ${{ steps.matrix.outputs.build_matrix }} publish_matrix: ${{ steps.matrix.outputs.publish_matrix }} s6_version: ${{ steps.s6.outputs.version }} steps: @@ -42,48 +42,65 @@ jobs: env: EVENT_NAME: ${{ github.event_name }} run: | - # The publish matrix is always the full set; the test matrix - # narrows the php-version axis on PRs so feedback stays fast. + # Two flat matrices are emitted from this step: + # build_matrix — per-arch build jobs (run on PR + main) + # publish_matrix — per-tuple merge jobs (main / schedule only) + # On PRs only amd64 is built so feedback stays fast and we don't + # spin up arm runners for unmergeable code. FULL_VERSIONS='["8.5","8.4","8.3","8.2"]' if [ "$EVENT_NAME" = "pull_request" ]; then TEST_VERSIONS='["8.5","8.2"]' - echo "::notice::PR detected — testing PHP 8.5 + 8.2 only (skipping 8.4 and 8.3)" + ARCHES='[ + {"arch":"amd64","runner":"ubuntu-latest","platform":"linux/amd64","qemu":false} + ]' + echo "::notice::PR detected — building amd64 only for PHP 8.5 + 8.2" else TEST_VERSIONS="$FULL_VERSIONS" + ARCHES='[ + {"arch":"amd64","runner":"ubuntu-latest","platform":"linux/amd64","qemu":false}, + {"arch":"arm64","runner":"ubuntu-24.04-arm","platform":"linux/arm64","qemu":false}, + {"arch":"armv7","runner":"ubuntu-latest","platform":"linux/arm/v7","qemu":true} + ]' fi - # build_matrix VERSIONS_JSON → matrix JSON string. variant × php-version - # × php-type × php-base, minus apache-on-alpine and v2-on-bookworm, - # plus an explicit v2/trixie include list (one per type). - build_matrix() { - local versions="$1" - local includes="[]" - for ver in $(echo "$versions" | jq -r '.[]'); do - for type in fpm cli apache; do - includes=$(echo "$includes" | jq -c \ - ". + [{\"variant\":\"v2\",\"php-version\":\"$ver\",\"php-type\":\"$type\",\"php-base\":\"trixie\"}]") - done - done - jq -n -c \ - --argjson versions "$versions" \ - --argjson includes "$includes" \ - '{ - "variant": ["v1","v2"], - "php-version": $versions, - "php-type": ["fpm","cli","apache"], - "php-base": ["alpine","bookworm"], - "exclude": [ - {"php-type":"apache","php-base":"alpine"}, - {"variant":"v2","php-base":"bookworm"} - ], - "include": $includes - }' + # gen_tuples VERSIONS_JSON → flat array of {variant, php-version, + # php-type, php-base} entries. Mirrors the prior matrix logic: + # cartesian product minus apache-on-alpine and v2-on-bookworm, + # plus an explicit v2/trixie row per (version, type). + gen_tuples() { + jq -n -c --argjson versions "$1" ' + [ + ["v1","v2"][] as $variant + | $versions[] as $ver + | ["fpm","cli","apache"][] as $type + | ["alpine","bookworm"][] as $base + | select( + ($type != "apache" or $base != "alpine") and + ($variant != "v2" or $base != "bookworm") + ) + | {variant: $variant, "php-version": $ver, "php-type": $type, "php-base": $base} + ] + + + [ + $versions[] as $ver + | ["fpm","cli","apache"][] as $type + | {variant: "v2", "php-version": $ver, "php-type": $type, "php-base": "trixie"} + ] + ' } - TEST_MATRIX=$(build_matrix "$TEST_VERSIONS") - PUBLISH_MATRIX=$(build_matrix "$FULL_VERSIONS") + TEST_TUPLES=$(gen_tuples "$TEST_VERSIONS") + PUBLISH_TUPLES=$(gen_tuples "$FULL_VERSIONS") + + BUILD_INCLUDES=$(jq -n -c \ + --argjson tuples "$TEST_TUPLES" \ + --argjson arches "$ARCHES" \ + '[$tuples[] as $t | $arches[] as $a | $t + $a]') - echo "test_matrix=$TEST_MATRIX" >> $GITHUB_OUTPUT + BUILD_MATRIX=$(jq -n -c --argjson includes "$BUILD_INCLUDES" '{include: $includes}') + PUBLISH_MATRIX=$(jq -n -c --argjson includes "$PUBLISH_TUPLES" '{include: $includes}') + + echo "build_matrix=$BUILD_MATRIX" >> $GITHUB_OUTPUT echo "publish_matrix=$PUBLISH_MATRIX" >> $GITHUB_OUTPUT - name: Get latest s6-overlay version @@ -101,54 +118,84 @@ jobs: echo "version=${S6_OVERLAY_VERSION}" >> $GITHUB_OUTPUT echo "✅ Latest s6-overlay version: ${S6_OVERLAY_VERSION}" - build-and-test: + build: needs: setup - runs-on: ubuntu-latest + runs-on: ${{ matrix.runner }} permissions: contents: read security-events: write + packages: write strategy: fail-fast: false - matrix: ${{ fromJson(needs.setup.outputs.test_matrix) }} - - name: ${{ matrix.variant }}-${{ matrix.php-version }}-${{ matrix.php-type }}-${{ matrix.php-base }} - + matrix: ${{ fromJson(needs.setup.outputs.build_matrix) }} + + name: ${{ matrix.variant }}-${{ matrix.php-version }}-${{ matrix.php-type }}-${{ matrix.php-base }}-${{ matrix.arch }} + + env: + PHP_VERSION: ${{ matrix.php-version }} + PHP_TYPE: ${{ matrix.php-type }} + PHP_BASE: ${{ matrix.php-base }} + VARIANT: ${{ matrix.variant }} + ARCH: ${{ matrix.arch }} + PLATFORM: ${{ matrix.platform }} + # Staging registry: per-arch images are pushed by digest here and the + # merge job copies them out to Docker Hub and Quay. + STAGING_NAME: ghcr.io/kingpin/php-docker + steps: - name: Checkout uses: actions/checkout@v6 + - name: Setup QEMU + if: matrix.qemu + uses: docker/setup-qemu-action@v4 + with: + platforms: ${{ matrix.platform }} + - name: Setup Docker Buildx uses: docker/setup-buildx-action@v4 - name: Set build variables id: vars run: | - VERSION="${{ matrix.php-version }}-${{ matrix.php-type }}-${{ matrix.php-base }}" - TAG_BASE="php-docker:${VERSION}" - - if [ "${{ matrix.variant }}" = "v2" ]; then - TAG="${TAG_BASE}-v2" + VERSION="${PHP_VERSION}-${PHP_TYPE}-${PHP_BASE}" + if [ "$VARIANT" = "v2" ]; then + TAG="${VERSION}-v2" DOCKERFILE="Dockerfile.v2" else - TAG="${TAG_BASE}" + TAG="${VERSION}" DOCKERFILE="Dockerfile.v1" fi - BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ") - + # Per-arch cache scope so concurrent matrix jobs don't fight. + CACHE_SCOPE="${VARIANT}-${VERSION}-${ARCH}" + echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT echo "TAG=${TAG}" >> $GITHUB_OUTPUT echo "DOCKERFILE=${DOCKERFILE}" >> $GITHUB_OUTPUT echo "BUILD_DATE=${BUILD_DATE}" >> $GITHUB_OUTPUT - echo "CACHE_SCOPE=${{ matrix.variant }}-${{ matrix.php-version }}-${{ matrix.php-type }}-${{ matrix.php-base }}" >> $GITHUB_OUTPUT + echo "CACHE_SCOPE=${CACHE_SCOPE}" >> $GITHUB_OUTPUT - - name: Build test image + - name: Login to GHCR + if: github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'schedule') + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + # amd64 jobs build with `load: true` so smoke tests + Trivy can run + # against the local docker daemon. On main this is a separate build + # from the digest-push below — both share the same GHA cache scope, + # so the second build is essentially a cache hit. + - name: Build amd64 test image + if: matrix.arch == 'amd64' uses: docker/build-push-action@v7 with: context: . file: ${{ steps.vars.outputs.DOCKERFILE }} load: true - platforms: linux/amd64 + platforms: ${{ matrix.platform }} cache-from: type=gha,scope=${{ steps.vars.outputs.CACHE_SCOPE }} cache-to: type=gha,mode=max,scope=${{ steps.vars.outputs.CACHE_SCOPE }} build-args: | @@ -161,16 +208,19 @@ jobs: tags: test-${{ steps.vars.outputs.TAG }} - name: Smoke tests - PHP version + if: matrix.arch == 'amd64' + env: + TEST_TAG: test-${{ steps.vars.outputs.TAG }} + EXPECTED_VERSION: ${{ matrix.php-version }} run: | echo "::group::Testing PHP version" - if ! docker run --rm test-${{ steps.vars.outputs.TAG }} php -v | tee php-version.txt; then + if ! docker run --rm "$TEST_TAG" php -v | tee php-version.txt; then echo "::error::Failed to run php -v" - docker logs test-${{ steps.vars.outputs.TAG }} 2>&1 || true + docker logs "$TEST_TAG" 2>&1 || true exit 1 fi - - if ! grep -q "${{ matrix.php-version }}" php-version.txt; then - echo "::error::PHP version mismatch - expected ${{ matrix.php-version }}" + if ! grep -q "$EXPECTED_VERSION" php-version.txt; then + echo "::error::PHP version mismatch - expected $EXPECTED_VERSION" cat php-version.txt exit 1 fi @@ -178,9 +228,12 @@ jobs: echo "::endgroup::" - name: Smoke tests - Basic PHP CLI run + if: matrix.arch == 'amd64' + env: + TEST_TAG: test-${{ steps.vars.outputs.TAG }} run: | echo "::group::Testing basic PHP CLI execution" - SAPI=$(docker run --rm test-${{ steps.vars.outputs.TAG }} php -r "echo PHP_SAPI;" 2>&1) + SAPI=$(docker run --rm "$TEST_TAG" php -r "echo PHP_SAPI;" 2>&1) if [ $? -ne 0 ]; then echo "::error::Failed to execute PHP CLI test" echo "$SAPI" @@ -190,18 +243,18 @@ jobs: echo "::endgroup::" - name: Smoke tests - Extensions + if: matrix.arch == 'amd64' + env: + TEST_TAG: test-${{ steps.vars.outputs.TAG }} run: | echo "::group::Testing PHP extensions" - if ! docker run --rm test-${{ steps.vars.outputs.TAG }} php -m | tee extensions.txt; then + if ! docker run --rm "$TEST_TAG" php -m | tee extensions.txt; then echo "::error::Failed to list PHP extensions" - docker logs test-${{ steps.vars.outputs.TAG }} 2>&1 || true + docker logs "$TEST_TAG" 2>&1 || true exit 1 fi - - # Core extensions that should be present REQUIRED_EXTS="gd json mysqli zip" MISSING_EXTS="" - for ext in $REQUIRED_EXTS; do if ! grep -qi "$ext" extensions.txt; then MISSING_EXTS="$MISSING_EXTS $ext" @@ -210,7 +263,6 @@ jobs: echo "✅ Extension $ext found" fi done - if [ -n "$MISSING_EXTS" ]; then echo "::error::Missing required extensions:$MISSING_EXTS" echo "Available extensions:" @@ -220,17 +272,18 @@ jobs: echo "::endgroup::" - name: Smoke tests - Entrypoint quick-run + if: matrix.arch == 'amd64' + env: + TEST_TAG: test-${{ steps.vars.outputs.TAG }} run: | echo "::group::Testing entrypoint/init quick-run" - OUTPUT=$(docker run --rm test-${{ steps.vars.outputs.TAG }} php -r "echo 'entrypoint-ok';" 2>&1) + OUTPUT=$(docker run --rm "$TEST_TAG" php -r "echo 'entrypoint-ok';" 2>&1) EXIT_CODE=$? - if [ $EXIT_CODE -ne 0 ]; then echo "::error::Entrypoint test failed with exit code $EXIT_CODE" echo "$OUTPUT" exit 1 fi - if ! echo "$OUTPUT" | grep -q "entrypoint-ok"; then echo "::error::Entrypoint did not produce expected output" echo "Output: $OUTPUT" @@ -240,12 +293,13 @@ jobs: echo "::endgroup::" - name: Smoke tests - Directory permissions + if: matrix.arch == 'amd64' + env: + TEST_TAG: test-${{ steps.vars.outputs.TAG }} run: | echo "::group::Testing directory permissions" - DIRS_TO_CHECK="/tmp /var/www" - - for dir in $DIRS_TO_CHECK; do - if ! docker run --rm test-${{ steps.vars.outputs.TAG }} sh -c "test -d $dir && [ -w $dir ]" 2>&1; then + for dir in /tmp /var/www; do + if ! docker run --rm "$TEST_TAG" sh -c "test -d $dir && [ -w $dir ]" 2>&1; then echo "::warning::Directory $dir either doesn't exist or is not writable" else echo "✅ Directory $dir exists and is writable" @@ -254,40 +308,37 @@ jobs: echo "::endgroup::" - name: Smoke tests - v2 specific (s6-overlay) - if: matrix.variant == 'v2' + if: matrix.arch == 'amd64' && matrix.variant == 'v2' + env: + TEST_TAG: test-${{ steps.vars.outputs.TAG }} run: | echo "::group::Testing s6-overlay presence and PID1 behavior" - - # Check s6-overlay directory - if ! docker run --rm test-${{ steps.vars.outputs.TAG }} sh -c "test -d /etc/s6-overlay" 2>&1; then + if ! docker run --rm "$TEST_TAG" sh -c "test -d /etc/s6-overlay" 2>&1; then echo "::error::s6-overlay directory not found at /etc/s6-overlay" - docker run --rm test-${{ steps.vars.outputs.TAG }} ls -la /etc/ 2>&1 || true + docker run --rm "$TEST_TAG" ls -la /etc/ 2>&1 || true exit 1 fi echo "✅ s6-overlay directory exists" - - # Check init binary - if ! docker run --rm test-${{ steps.vars.outputs.TAG }} sh -c "test -f /init" 2>&1; then + if ! docker run --rm "$TEST_TAG" sh -c "test -f /init" 2>&1; then echo "::error::s6 init binary not found at /init" - docker run --rm test-${{ steps.vars.outputs.TAG }} ls -la / 2>&1 || true + docker run --rm "$TEST_TAG" ls -la / 2>&1 || true exit 1 fi echo "✅ s6 init binary exists" - - # Check for s6-overlay services directory - if ! docker run --rm test-${{ steps.vars.outputs.TAG }} sh -c "test -d /etc/s6-overlay/s6-rc.d || test -d /etc/services.d" 2>&1; then - echo "::warning::s6 services directory not found (expected /etc/s6-overlay/s6-rc.d or /etc/services.d)" + if ! docker run --rm "$TEST_TAG" sh -c "test -d /etc/s6-overlay/s6-rc.d || test -d /etc/services.d" 2>&1; then + echo "::warning::s6 services directory not found" else echo "✅ s6 services directory found" fi - echo "::endgroup::" - name: Smoke tests - FPM specific - if: matrix.php-type == 'fpm' + if: matrix.arch == 'amd64' && matrix.php-type == 'fpm' + env: + TEST_TAG: test-${{ steps.vars.outputs.TAG }} run: | echo "::group::Testing PHP-FPM" - if ! docker run --rm test-${{ steps.vars.outputs.TAG }} php-fpm --version 2>&1 | tee fpm-version.txt; then + if ! docker run --rm "$TEST_TAG" php-fpm --version 2>&1 | tee fpm-version.txt; then echo "::error::Failed to run php-fpm --version" cat fpm-version.txt || true exit 1 @@ -296,10 +347,12 @@ jobs: echo "::endgroup::" - name: Smoke tests - Apache specific - if: matrix.php-type == 'apache' + if: matrix.arch == 'amd64' && matrix.php-type == 'apache' + env: + TEST_TAG: test-${{ steps.vars.outputs.TAG }} run: | echo "::group::Testing Apache" - if ! docker run --rm test-${{ steps.vars.outputs.TAG }} apache2 -v 2>&1 | tee apache-version.txt; then + if ! docker run --rm "$TEST_TAG" apache2 -v 2>&1 | tee apache-version.txt; then echo "::error::Failed to run apache2 -v" cat apache-version.txt || true exit 1 @@ -307,11 +360,11 @@ jobs: echo "✅ Apache version check passed" echo "::endgroup::" - # Scan the locally-loaded test image (amd64) before publish gets a - # chance to run. exit-code: 1 turns CRITICAL/HIGH findings into a job - # failure, so vulnerable images never reach the registries. - # ignore-unfixed: true suppresses advisories with no upstream fix yet. + # Trivy gates the publish step. exit-code: 1 fails the job on + # CRITICAL/HIGH so vulnerable images can't reach the registries. + # ignore-unfixed: true filters out advisories with no upstream fix. - name: Trivy vulnerability scan + if: matrix.arch == 'amd64' uses: aquasecurity/trivy-action@master with: scan-type: image @@ -323,34 +376,85 @@ jobs: output: 'trivy-results-${{ matrix.variant }}-${{ matrix.php-version }}-${{ matrix.php-type }}-${{ matrix.php-base }}.sarif' - name: Upload Trivy results - if: always() + if: matrix.arch == 'amd64' && always() uses: github/codeql-action/upload-sarif@v4 with: sarif_file: 'trivy-results-${{ matrix.variant }}-${{ matrix.php-version }}-${{ matrix.php-type }}-${{ matrix.php-base }}.sarif' + category: trivy-${{ matrix.variant }}-${{ matrix.php-version }}-${{ matrix.php-type }}-${{ matrix.php-base }} + + # Push the per-arch image to the staging registry (GHCR) by digest. + # The merge job will pull these digests and assemble final manifests + # on Docker Hub, GHCR, and Quay with the proper tags. + - name: Build and push by digest + if: github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'schedule') + id: push + uses: docker/build-push-action@v7 + with: + context: . + file: ${{ steps.vars.outputs.DOCKERFILE }} + platforms: ${{ matrix.platform }} + provenance: mode=max + sbom: true + cache-from: type=gha,scope=${{ steps.vars.outputs.CACHE_SCOPE }} + cache-to: type=gha,mode=max,scope=${{ steps.vars.outputs.CACHE_SCOPE }} + outputs: type=image,name=${{ env.STAGING_NAME }},push-by-digest=true,name-canonical=true,push=true + build-args: | + VERSION=${{ steps.vars.outputs.VERSION }} + PHPVERSION=${{ matrix.php-version }} + BASEOS=${{ matrix.php-base }} + S6_OVERLAY_VERSION=${{ needs.setup.outputs.s6_version }} + BUILD_DATE=${{ steps.vars.outputs.BUILD_DATE }} + VCS_REF=${{ github.sha }} + labels: | + com.sumguy.php-docker.php.variant=${{ matrix.php-type }} + com.sumguy.php-docker.image.variant=${{ matrix.variant }} + com.sumguy.php-docker.build_id=${{ github.run_id }} + com.sumguy.php-docker.build_url=${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + com.sumguy.php-docker.built_by=github-actions/docker-ci + + - name: Export digest + if: github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'schedule') + env: + DIGEST: ${{ steps.push.outputs.digest }} + run: | + mkdir -p /tmp/digests + touch "/tmp/digests/${DIGEST#sha256:}" + + - name: Upload digest + if: github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'schedule') + uses: actions/upload-artifact@v4 + with: + name: digests-${{ matrix.variant }}-${{ matrix.php-version }}-${{ matrix.php-type }}-${{ matrix.php-base }}-${{ matrix.arch }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 - name: Summary run: | - echo "::notice::✅ Build and tests passed for ${{ matrix.variant }} - ${{ steps.vars.outputs.TAG }}" + echo "::notice::✅ Build passed for ${{ matrix.variant }} ${{ steps.vars.outputs.TAG }} on ${{ matrix.arch }}" - publish: - needs: [setup, build-and-test] + publish-merge: + needs: [setup, build] if: github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'schedule') runs-on: ubuntu-latest + permissions: + contents: read + packages: write strategy: fail-fast: false matrix: ${{ fromJson(needs.setup.outputs.publish_matrix) }} - + name: publish-${{ matrix.variant }}-${{ matrix.php-version }}-${{ matrix.php-type }}-${{ matrix.php-base }} - - steps: - - name: Checkout - uses: actions/checkout@v6 - - name: Setup QEMU - uses: docker/setup-qemu-action@v4 - with: - platforms: amd64,arm64,arm + env: + PHP_VERSION: ${{ matrix.php-version }} + PHP_TYPE: ${{ matrix.php-type }} + PHP_BASE: ${{ matrix.php-base }} + VARIANT: ${{ matrix.variant }} + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + STAGING_NAME: ghcr.io/kingpin/php-docker + steps: - name: Setup Docker Buildx uses: docker/setup-buildx-action@v4 @@ -374,81 +478,60 @@ jobs: username: ${{ secrets.QUAY_USERNAME }} password: ${{ secrets.QUAY_ROBOT_TOKEN }} - - name: Set publish variables - id: vars - env: - PHP_VERSION: ${{ matrix.php-version }} - PHP_TYPE: ${{ matrix.php-type }} - PHP_BASE: ${{ matrix.php-base }} - VARIANT: ${{ matrix.variant }} - DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + - name: Download per-arch digests + uses: actions/download-artifact@v4 + with: + pattern: digests-${{ matrix.variant }}-${{ matrix.php-version }}-${{ matrix.php-type }}-${{ matrix.php-base }}-* + merge-multiple: true + path: /tmp/digests + + - name: Create manifest lists run: | + set -euo pipefail VERSION="${PHP_VERSION}-${PHP_TYPE}-${PHP_BASE}" - if [ "$VARIANT" = "v2" ]; then - TAG_SUFFIX="-v2" - DOCKERFILE="Dockerfile.v2" + SUFFIX="-v2" else - TAG_SUFFIX="" - DOCKERFILE="Dockerfile.v1" + SUFFIX="" + fi + PRIMARY_TAG="${VERSION}${SUFFIX}" + # v2/trixie also gets a bookworm-aliased tag so existing consumers + # of `--bookworm-v2` keep resolving to the new image. + EXTRA_TAGS="" + if [ "$VARIANT" = "v2" ] && [ "$PHP_BASE" = "trixie" ]; then + EXTRA_TAGS="${PHP_VERSION}-${PHP_TYPE}-bookworm${SUFFIX}" fi - BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ") - - DOCKERHUB_TAG="docker.io/${DOCKERHUB_USERNAME}/php-docker:${VERSION}${TAG_SUFFIX}" - GHCR_TAG="ghcr.io/kingpin/php-docker:${VERSION}${TAG_SUFFIX}" - QUAY_TAG="quay.io/kingpinx1/php-docker:${VERSION}${TAG_SUFFIX}" - - echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT - echo "TAG_SUFFIX=${TAG_SUFFIX}" >> $GITHUB_OUTPUT - echo "DOCKERFILE=${DOCKERFILE}" >> $GITHUB_OUTPUT - echo "BUILD_DATE=${BUILD_DATE}" >> $GITHUB_OUTPUT - echo "DOCKERHUB_TAG=${DOCKERHUB_TAG}" >> $GITHUB_OUTPUT - echo "GHCR_TAG=${GHCR_TAG}" >> $GITHUB_OUTPUT - echo "QUAY_TAG=${QUAY_TAG}" >> $GITHUB_OUTPUT - echo "CACHE_SCOPE=${VARIANT}-${VERSION}" >> $GITHUB_OUTPUT - - # Build the multi-line TAGS output for build-push-action. Always - # includes the primary three. For v2/trixie builds, also alias to - # the bookworm tag so existing consumers don't break — formerly - # done in a separate `docker buildx imagetools create` step. - { - echo "TAGS<> $GITHUB_OUTPUT - - - name: Build and push multi-arch image - uses: docker/build-push-action@v7 - with: - context: . - file: ${{ steps.vars.outputs.DOCKERFILE }} - platforms: linux/amd64,linux/arm64,linux/arm/v7 - push: true - provenance: mode=max - sbom: true - cache-from: type=gha,scope=${{ steps.vars.outputs.CACHE_SCOPE }} - cache-to: type=gha,mode=max,scope=${{ steps.vars.outputs.CACHE_SCOPE }} - build-args: | - VERSION=${{ steps.vars.outputs.VERSION }} - PHPVERSION=${{ matrix.php-version }} - BASEOS=${{ matrix.php-base }} - S6_OVERLAY_VERSION=${{ needs.setup.outputs.s6_version }} - BUILD_DATE=${{ steps.vars.outputs.BUILD_DATE }} - VCS_REF=${{ github.sha }} - tags: ${{ steps.vars.outputs.TAGS }} - labels: | - com.sumguy.php-docker.php.variant=${{ matrix.php-type }} - com.sumguy.php-docker.image.variant=${{ matrix.variant }} - com.sumguy.php-docker.build_id=${{ github.run_id }} - com.sumguy.php-docker.build_url=${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - com.sumguy.php-docker.built_by=github-actions/docker-ci + cd /tmp/digests + DIGEST_FILES=$(ls -1) + if [ -z "$DIGEST_FILES" ]; then + echo "::error::No digests found for $VERSION$SUFFIX" + exit 1 + fi + # imagetools create copies blobs across registries as needed; the + # per-arch images live on GHCR (staging) and the final manifests + # land on all three registries with all the desired tags. + for reg in \ + "docker.io/${DOCKERHUB_USERNAME}/php-docker" \ + "ghcr.io/kingpin/php-docker" \ + "quay.io/kingpinx1/php-docker"; do + T_ARGS="-t ${reg}:${PRIMARY_TAG}" + for et in $EXTRA_TAGS; do + T_ARGS="${T_ARGS} -t ${reg}:${et}" + done + S_ARGS="" + for d in $DIGEST_FILES; do + S_ARGS="${S_ARGS} ${STAGING_NAME}@sha256:${d}" + done + echo "::group::imagetools create ${reg}:${PRIMARY_TAG}" + # shellcheck disable=SC2086 + docker buildx imagetools create $T_ARGS $S_ARGS + echo "::endgroup::" + done + - name: Inspect final manifest + run: | + set -euo pipefail + VERSION="${PHP_VERSION}-${PHP_TYPE}-${PHP_BASE}" + if [ "$VARIANT" = "v2" ]; then SUFFIX="-v2"; else SUFFIX=""; fi + docker buildx imagetools inspect "docker.io/${DOCKERHUB_USERNAME}/php-docker:${VERSION}${SUFFIX}" From b1f62ab4a9cae3f941cfd5d04c9e4db067ff00af Mon Sep 17 00:00:00 2001 From: KingPin <28669+KingPin@users.noreply.github.com> Date: Mon, 18 May 2026 12:03:13 -0400 Subject: [PATCH 09/10] fix(docker): apk upgrade in Alpine branches to clear Trivy CVE gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Debian branches already run `apt-get -y upgrade` to pull base image security patches at build time, but the Alpine branches only ran `apk update` followed by `apk add` — leaving base layer packages at whatever versions the upstream php:*-alpine tag was published with. When the ECR-hosted base image lags upstream Alpine point releases (e.g. 3.22.1 vs 3.23.x), Trivy's CRITICAL/HIGH gate trips on fixed CVEs that an `apk upgrade` would patch immediately. Add `apk upgrade --no-cache` between `apk update` and `apk add` in both Dockerfile.v1 and Dockerfile.v2, mirroring the Debian behavior. Verified locally: v1-8.2-cli-alpine now reports 0 CRITICAL/HIGH CVEs. --- Dockerfile.v1 | 1 + Dockerfile.v2 | 1 + 2 files changed, 2 insertions(+) diff --git a/Dockerfile.v1 b/Dockerfile.v1 index 07e0d22..61e7dd7 100644 --- a/Dockerfile.v1 +++ b/Dockerfile.v1 @@ -18,6 +18,7 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked,id=apt-$BASEOS \ apt-get install -y --no-install-recommends curl git zip unzip ghostscript imagemagick optipng gifsicle pngcrush jpegoptim libjpeg-turbo-progs pngquant webp; \ elif [ "$BASEOS" = "alpine" ]; then \ apk update && \ + apk upgrade --no-cache && \ apk add --no-cache curl git zip unzip ghostscript imagemagick optipng gifsicle pngcrush jpegoptim libjpeg-turbo libjpeg-turbo-utils pngquant libwebp-tools; \ fi diff --git a/Dockerfile.v2 b/Dockerfile.v2 index b852dfc..d782148 100644 --- a/Dockerfile.v2 +++ b/Dockerfile.v2 @@ -120,6 +120,7 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked,id=apt-$BASEOS \ fi; \ elif [ "$BASEOS" = "alpine" ]; then \ apk update && \ + apk upgrade --no-cache && \ apk add --no-cache \ build-base \ curl git zip unzip wget ca-certificates xz \ From 1c82084ee85ef4fb41c3fa583db9b595ef34c7c9 Mon Sep 17 00:00:00 2001 From: KingPin <28669+KingPin@users.noreply.github.com> Date: Mon, 18 May 2026 13:13:51 -0400 Subject: [PATCH 10/10] ci: inspect every published (registry, tag) after manifest merge The post-publish inspect step only verified the Docker Hub primary tag. If `imagetools create` produced a malformed manifest on GHCR or Quay, or the v2/trixie -> bookworm alias tag failed to land, the job would still report success. Iterate over the same three registries and tag list used in the create step so any partial publish fails the job. --- .github/workflows/docker-ci.yml | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docker-ci.yml b/.github/workflows/docker-ci.yml index bcc3ef5..33abdcc 100644 --- a/.github/workflows/docker-ci.yml +++ b/.github/workflows/docker-ci.yml @@ -529,9 +529,25 @@ jobs: echo "::endgroup::" done - - name: Inspect final manifest + - name: Inspect final manifests run: | set -euo pipefail VERSION="${PHP_VERSION}-${PHP_TYPE}-${PHP_BASE}" if [ "$VARIANT" = "v2" ]; then SUFFIX="-v2"; else SUFFIX=""; fi - docker buildx imagetools inspect "docker.io/${DOCKERHUB_USERNAME}/php-docker:${VERSION}${SUFFIX}" + PRIMARY_TAG="${VERSION}${SUFFIX}" + EXTRA_TAGS="" + if [ "$VARIANT" = "v2" ] && [ "$PHP_BASE" = "trixie" ]; then + EXTRA_TAGS="${PHP_VERSION}-${PHP_TYPE}-bookworm${SUFFIX}" + fi + # Inspect every (registry, tag) combination produced by the create + # step so a malformed push or missing alias tag fails the job. + for reg in \ + "docker.io/${DOCKERHUB_USERNAME}/php-docker" \ + "ghcr.io/kingpin/php-docker" \ + "quay.io/kingpinx1/php-docker"; do + for tag in "$PRIMARY_TAG" $EXTRA_TAGS; do + echo "::group::imagetools inspect ${reg}:${tag}" + docker buildx imagetools inspect "${reg}:${tag}" + echo "::endgroup::" + done + done