Docker CI (v1 + v2) #135
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Docker CI (v1 + v2) | |
| on: | |
| push: | |
| # Only run full CI on pushes to main (publishes/metrics). Running on | |
| # every branch push plus pull_request creates duplicate runs when a PR | |
| # receives new commits because GitHub emits both push and pull_request | |
| # events for the same push. Restrict push to main to avoid that. | |
| # | |
| # paths: include-list of build-relevant files only — keeps unrelated | |
| # changes (other workflows, docs, repo housekeeping) from triggering the | |
| # full image matrix. Update this list when adding new files that affect | |
| # the built image or the build pipeline itself. | |
| branches: | |
| - 'main' | |
| paths: | |
| - 'Dockerfile.v1' | |
| - 'Dockerfile.v2' | |
| - 's6-overlay/**' | |
| - 'extras/retry.sh' | |
| - '.github/workflows/docker-ci.yml' | |
| pull_request: | |
| branches: | |
| - '**' | |
| paths: | |
| - 'Dockerfile.v1' | |
| - 'Dockerfile.v2' | |
| - 's6-overlay/**' | |
| - 'extras/retry.sh' | |
| - '.github/workflows/docker-ci.yml' | |
| schedule: | |
| - cron: '0 3 * * 2' # Weekly on Tuesday at 3:00 AM UTC | |
| workflow_dispatch: | |
| concurrency: | |
| group: docker-ci-${{ github.ref }} | |
| cancel-in-progress: true | |
| jobs: | |
| setup: | |
| runs-on: ubuntu-latest | |
| outputs: | |
| build_matrix: ${{ steps.matrix.outputs.build_matrix }} | |
| publish_matrix: ${{ steps.matrix.outputs.publish_matrix }} | |
| arch_count: ${{ steps.matrix.outputs.arch_count }} | |
| s6_version: ${{ steps.s6.outputs.version }} | |
| steps: | |
| - name: Compute build matrices | |
| id: matrix | |
| env: | |
| EVENT_NAME: ${{ github.event_name }} | |
| run: | | |
| # 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 | |
| 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 | |
| # 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]') | |
| 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 | |
| # Expected number of per-arch builds per tuple. The publish job uses | |
| # this to refuse a partial-arch manifest when a build leg flaked. | |
| echo "arch_count=$(echo "$ARCHES" | jq 'length')" >> $GITHUB_OUTPUT | |
| - name: Get latest s6-overlay version | |
| id: s6 | |
| run: | | |
| set -euo pipefail | |
| RESPONSE="$(curl -fSLs \ | |
| -H "Authorization: Bearer ${{ github.token }}" \ | |
| https://api.github.com/repos/just-containers/s6-overlay/releases/latest)" | |
| S6_OVERLAY_VERSION="$(echo "$RESPONSE" | jq -r .tag_name)" | |
| if [ -z "$S6_OVERLAY_VERSION" ] || [ "$S6_OVERLAY_VERSION" = "null" ]; then | |
| echo "::error::Failed to determine s6-overlay version" | |
| exit 1 | |
| fi | |
| echo "version=${S6_OVERLAY_VERSION}" >> $GITHUB_OUTPUT | |
| echo "✅ Latest s6-overlay version: ${S6_OVERLAY_VERSION}" | |
| build: | |
| needs: setup | |
| runs-on: ${{ matrix.runner }} | |
| permissions: | |
| contents: read | |
| security-events: write | |
| packages: write | |
| strategy: | |
| fail-fast: false | |
| 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@v7 | |
| - 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="${PHP_VERSION}-${PHP_TYPE}-${PHP_BASE}" | |
| if [ "$VARIANT" = "v2" ]; then | |
| TAG="${VERSION}-v2" | |
| DOCKERFILE="Dockerfile.v2" | |
| else | |
| 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=${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 }} | |
| # 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: ${{ 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: | | |
| 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: 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_TAG" php -v | tee php-version.txt; then | |
| echo "::error::Failed to run php -v" | |
| docker logs "$TEST_TAG" 2>&1 || true | |
| exit 1 | |
| fi | |
| if ! grep -q "$EXPECTED_VERSION" php-version.txt; then | |
| echo "::error::PHP version mismatch - expected $EXPECTED_VERSION" | |
| cat php-version.txt | |
| exit 1 | |
| fi | |
| echo "✅ PHP version correct" | |
| 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_TAG" php -r "echo PHP_SAPI;" 2>&1) | |
| if [ $? -ne 0 ]; then | |
| echo "::error::Failed to execute PHP CLI test" | |
| echo "$SAPI" | |
| exit 1 | |
| fi | |
| echo "✅ PHP CLI runs successfully (SAPI: $SAPI)" | |
| 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_TAG" php -m | tee extensions.txt; then | |
| echo "::error::Failed to list PHP extensions" | |
| docker logs "$TEST_TAG" 2>&1 || true | |
| exit 1 | |
| fi | |
| 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" | |
| echo "::error::Missing extension: $ext" | |
| else | |
| echo "✅ Extension $ext found" | |
| fi | |
| done | |
| if [ -n "$MISSING_EXTS" ]; then | |
| echo "::error::Missing required extensions:$MISSING_EXTS" | |
| echo "Available extensions:" | |
| cat extensions.txt | |
| exit 1 | |
| fi | |
| 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_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" | |
| exit 1 | |
| fi | |
| echo "✅ Entrypoint executes successfully" | |
| 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" | |
| 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" | |
| fi | |
| done | |
| echo "::endgroup::" | |
| - name: Smoke tests - v2 specific (s6-overlay) | |
| 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" | |
| 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_TAG" ls -la /etc/ 2>&1 || true | |
| exit 1 | |
| fi | |
| echo "✅ s6-overlay directory exists" | |
| 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_TAG" ls -la / 2>&1 || true | |
| exit 1 | |
| fi | |
| echo "✅ s6 init binary exists" | |
| 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.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_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 | |
| fi | |
| echo "✅ PHP-FPM version check passed" | |
| echo "::endgroup::" | |
| - name: Smoke tests - Apache specific | |
| 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_TAG" apache2 -v 2>&1 | tee apache-version.txt; then | |
| echo "::error::Failed to run apache2 -v" | |
| cat apache-version.txt || true | |
| exit 1 | |
| fi | |
| echo "✅ Apache version check passed" | |
| echo "::endgroup::" | |
| # Report-only scan: findings are uploaded to the Security tab but never | |
| # block the build or publish. The OS layer comes from upstream base | |
| # images, so CRITICAL/HIGH CVEs there are tracked for visibility rather | |
| # than gated on (we can't patch faster than upstream rebuilds). | |
| # 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: '0' | |
| 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@v7 | |
| 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 passed for ${{ matrix.variant }} ${{ steps.vars.outputs.TAG }} on ${{ matrix.arch }}" | |
| publish-merge: | |
| needs: [setup, build] | |
| # Run per-tuple even when SOME build legs failed. `build` is one matrix | |
| # job, so a single failed leg would otherwise mark it failed and skip | |
| # every publish. `!cancelled() && needs.setup.result == 'success'` lets | |
| # publish proceed regardless of build's aggregate result; the digest | |
| # completeness guard below fails only the tuples that are actually | |
| # missing an arch, so one flake no longer blocks all ~30 publishes. | |
| if: >- | |
| !cancelled() | |
| && needs.setup.result == 'success' | |
| && 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 }} | |
| 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 | |
| EXPECTED_ARCHES: ${{ needs.setup.outputs.arch_count }} | |
| steps: | |
| - name: Setup Docker Buildx | |
| uses: docker/setup-buildx-action@v4 | |
| - name: Login to DockerHub | |
| uses: docker/login-action@v4 | |
| with: | |
| username: ${{ secrets.DOCKERHUB_USERNAME }} | |
| password: ${{ secrets.DOCKERHUB_TOKEN }} | |
| - name: Login to GHCR | |
| uses: docker/login-action@v4 | |
| with: | |
| registry: ghcr.io | |
| username: ${{ github.repository_owner }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Login to Quay.io | |
| uses: docker/login-action@v4 | |
| with: | |
| registry: quay.io | |
| username: ${{ secrets.QUAY_USERNAME }} | |
| password: ${{ secrets.QUAY_ROBOT_TOKEN }} | |
| - name: Download per-arch digests | |
| uses: actions/download-artifact@v8 | |
| 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 | |
| SUFFIX="-v2" | |
| else | |
| SUFFIX="" | |
| fi | |
| PRIMARY_TAG="${VERSION}${SUFFIX}" | |
| # v2/trixie also gets a bookworm-aliased tag so existing consumers | |
| # of `<ver>-<type>-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 | |
| mkdir -p /tmp/digests | |
| cd /tmp/digests | |
| DIGEST_FILES=$(ls -1) | |
| DIGEST_COUNT=$(ls -1 | wc -l | tr -d ' ') | |
| # Completeness guard: refuse to publish unless every expected arch | |
| # built. A partial set means a build leg flaked — publishing a | |
| # 2-of-3-arch manifest would silently break consumers on the missing | |
| # arch. Fail only THIS tuple (re-runnable via `gh run rerun | |
| # --failed`); other tuples publish normally. | |
| if [ "$DIGEST_COUNT" -eq 0 ]; then | |
| echo "::error::No digests found for $VERSION$SUFFIX — all arch builds for this tuple failed. Re-run failed build jobs, then re-run publish." | |
| exit 1 | |
| fi | |
| if [ "$DIGEST_COUNT" -lt "${EXPECTED_ARCHES:-0}" ]; then | |
| echo "::error::Incomplete arch set for $VERSION$SUFFIX: found $DIGEST_COUNT of $EXPECTED_ARCHES arch digests. Refusing to publish a partial-arch manifest. Re-run the failed build job, then re-run this publish." | |
| 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 manifests | |
| run: | | |
| 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 |