From ccc403849f5c36a4b950bfbc30c9f01d98d02c05 Mon Sep 17 00:00:00 2001 From: linkrobins Date: Tue, 28 Jul 2026 20:11:43 -0400 Subject: [PATCH 1/3] Build each architecture natively instead of emulating arm64 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The publish job built linux/amd64 and linux/arm64 together under QEMU. This image compiles php extensions (gd, intl) during the build, which is close to the worst case for emulation — the last run spent over half an hour on publish while the round-trip test beside it finished in a few minutes. GitHub gives public repositories free arm64 runners, so nothing needs emulating. Each arch now builds on its own native runner and pushes by digest, and a small merge job stitches the digests into one multi-arch tag. Two details worth keeping: the build cache is scoped per-architecture, since a shared scope has the two jobs overwrite each other's layers; and no per-arch tags are pushed, since a tag written by both jobs ends up pointing at whichever finished last. The merge job names the image exactly once, then asserts both architectures are actually in the manifest so a silently half-published tag fails the run instead of shipping. --- .github/workflows/ci.yml | 97 +++++++++++++++++++++++++++++++++------- 1 file changed, 81 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c246235..1c08986 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -127,19 +127,80 @@ jobs: if: failure() run: docker compose logs --tail=300 - # Build + push the image to GitHub Container Registry — only on main / version - # tags, and only after the round-trip above passes, so we never publish an - # image that failed the build/backup/restore test. - publish: + # Build each architecture on its OWN native runner, push by digest, then stitch + # the digests into one multi-arch tag. + # + # This used to be a single job building linux/amd64,linux/arm64 together with + # QEMU. That emulates arm64 instruction-for-instruction, and this image + # COMPILES php extensions (gd, intl) during the build, which is close to the + # worst case for emulation — the publish step ran ~30 minutes while the + # round-trip test beside it finished in a few. GitHub provides free arm64 + # runners for public repositories, so neither architecture needs emulating. + # + # Only on main / version tags, and only after the round-trip passes, so a + # failed build/backup/restore is never published. + build: needs: roundtrip if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) - runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-latest + - platform: linux/arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} permissions: contents: read packages: write steps: - uses: actions/checkout@v4 - - uses: docker/setup-qemu-action@v3 + - uses: docker/setup-buildx-action@v3 + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build + push by digest + id: build + uses: docker/build-push-action@v6 + with: + context: . + platforms: ${{ matrix.platform }} + # No tag here: each arch is pushed as a bare digest and only the merge + # job below names it. Tagging per-arch would leave the tag pointing at + # whichever architecture happened to finish last. + outputs: type=image,name=ghcr.io/${{ github.repository }},push-by-digest=true,name-canonical=true,push=true + # Cache is scoped per-architecture — a shared scope would have the two + # jobs overwrite each other's layers. + cache-from: type=gha,scope=${{ matrix.platform }} + cache-to: type=gha,mode=max,scope=${{ matrix.platform }} + - name: Export digest + run: | + mkdir -p /tmp/digests + touch "/tmp/digests/${{ steps.build.outputs.digest }}" + - uses: actions/upload-artifact@v4 + with: + # One artifact per arch; the merge job globs them back together. + name: digest-${{ strategy.job-index }} + path: /tmp/digests/* + retention-days: 1 + if-no-files-found: error + + publish: + needs: build + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/download-artifact@v4 + with: + path: /tmp/digests + pattern: digest-* + merge-multiple: true - uses: docker/setup-buildx-action@v3 - name: Log in to GHCR uses: docker/login-action@v3 @@ -157,13 +218,17 @@ jobs: type=sha,format=short type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} - - name: Build + push (amd64 + arm64) - uses: docker/build-push-action@v6 - with: - context: . - platforms: linux/amd64,linux/arm64 - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max + - name: Create the multi-arch manifest + working-directory: /tmp/digests + run: | + docker buildx imagetools create \ + $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf 'ghcr.io/${{ github.repository }}@sha256:%s ' *) + - name: Verify both architectures are present + run: | + docker buildx imagetools inspect \ + "ghcr.io/${{ github.repository }}:$(jq -r '.tags[0] | split(":")[1]' <<< "$DOCKER_METADATA_OUTPUT_JSON")" \ + --format '{{json .Manifest}}' | jq -e ' + [.manifests[].platform | select(.os != "unknown") | "\(.os)/\(.architecture)"] as $p + | if ($p | index("linux/amd64")) and ($p | index("linux/arm64")) + then "ok" else error("missing an architecture: \($p)") end' From 3957d1658ae1f36b1e46e078f80eb6738ab03fec Mon Sep 17 00:00:00 2001 From: linkrobins Date: Tue, 28 Jul 2026 20:16:24 -0400 Subject: [PATCH 2/3] Let the publish path be exercised from a branch build/publish only ran on main or a tag, so a change to the build itself could not be tested before merging: the pull request showed a green roundtrip and said nothing about whether the image still publishes. Adding workflow_dispatch makes this very change verifiable on its branch. --- .github/workflows/ci.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1c08986..cf2b1ac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -141,7 +141,15 @@ jobs: # failed build/backup/restore is never published. build: needs: roundtrip - if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) + # workflow_dispatch is included so the publish path can be exercised from a + # branch. Without it these jobs only ever run on main or a tag, which means + # a change to the build itself cannot be tested before it is merged — the + # pull request would show a green roundtrip and tell you nothing about + # whether the image still publishes. + if: >- + github.event_name == 'workflow_dispatch' || + (github.event_name == 'push' && + (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))) strategy: fail-fast: false matrix: From f24d7a6136ce640b9bba372a0cb69e85459bbbe7 Mon Sep 17 00:00:00 2001 From: linkrobins Date: Tue, 28 Jul 2026 20:29:05 -0400 Subject: [PATCH 3/3] Strip the sha256: prefix before uploading the digest actions/upload-artifact rejects ':' in a filename, so the export step failed after the image had already built and pushed successfully. The merge job was already written to expect the bare hex and re-add the prefix; only the export half was wrong. --- .github/workflows/ci.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cf2b1ac..9e82843 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -188,7 +188,11 @@ jobs: - name: Export digest run: | mkdir -p /tmp/digests - touch "/tmp/digests/${{ steps.build.outputs.digest }}" + # Store the BARE hex, not the full "sha256:..." digest: + # actions/upload-artifact rejects ':' in a filename. The merge job + # puts the sha256: prefix back when it builds the manifest. + digest='${{ steps.build.outputs.digest }}' + touch "/tmp/digests/${digest#sha256:}" - uses: actions/upload-artifact@v4 with: # One artifact per arch; the merge job globs them back together.