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/.github/workflows/docker-ci.yml b/.github/workflows/docker-ci.yml index 94878ea..33abdcc 100644 --- a/.github/workflows/docker-ci.yml +++ b/.github/workflows/docker-ci.yml @@ -33,43 +33,75 @@ jobs: setup: runs-on: ubuntu-latest outputs: - matrix: ${{ steps.matrix.outputs.matrix }} + build_matrix: ${{ steps.matrix.outputs.build_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"]' - echo "::notice::PR detected — testing PHP 8.5 + 8.2 only (skipping 8.4 and 8.3)" + # 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"]' + 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 - PHP_VERSIONS='["8.5","8.4","8.3","8.2"]' + 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 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\"}]") - done - done + # 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_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]') - 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 - }') - - echo "matrix=$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 id: s6 @@ -86,51 +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.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: 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 }} - - name: Build test image + # 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: | @@ -143,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 @@ -160,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" @@ -172,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" @@ -192,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:" @@ -202,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" @@ -222,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" @@ -236,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 @@ -278,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 @@ -289,89 +360,101 @@ jobs: echo "✅ Apache version check passed" echo "::endgroup::" + # 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 + 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: 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: - 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 }} - - 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 @@ -395,92 +478,76 @@ jobs: username: ${{ secrets.QUAY_USERNAME }} password: ${{ secrets.QUAY_ROBOT_TOKEN }} - - name: Set publish variables - id: vars + - 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: | - VERSION="${{ matrix.php-version }}-${{ matrix.php-type }}-${{ matrix.php-base }}" - - if [ "${{ matrix.variant }}" = "v2" ]; then - TAG_SUFFIX="-v2" - DOCKERFILE="Dockerfile.v2" + set -euo pipefail + VERSION="${PHP_VERSION}-${PHP_TYPE}-${PHP_BASE}" + if [ "$VARIANT" = "v2" ]; then + 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") - - 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 - - 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 - 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.DOCKERHUB_TAG }} - ${{ steps.vars.outputs.GHCR_TAG }} - ${{ steps.vars.outputs.QUAY_TAG }} - 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: Create bookworm compatibility tag for v2 trixie images - if: matrix.variant == 'v2' && matrix.php-base == 'trixie' + - name: Inspect final manifests 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: - 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' + set -euo pipefail + VERSION="${PHP_VERSION}-${PHP_TYPE}-${PHP_BASE}" + if [ "$VARIANT" = "v2" ]; then SUFFIX="-v2"; else SUFFIX=""; fi + 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 diff --git a/Dockerfile.v1 b/Dockerfile.v1 index c1fa958..61e7dd7 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 \ @@ -19,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 @@ -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..d782148 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" \ @@ -66,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 && \ @@ -109,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 \ @@ -139,7 +151,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 \ @@ -264,13 +276,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 []