From 96a625c1a463ff1df8264b61c3a18a603699c124 Mon Sep 17 00:00:00 2001 From: rancur <235745911+rancur@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:11:09 -0700 Subject: [PATCH] ci: build release images on native runners instead of QEMU MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cross-building arm64 through docker/setup-qemu-action was pathologically slow for the Next.js image. Measured on the 2.12.2 release: api 6 min, worker 9 min, web 46+ minutes and still going when it was cancelled. That would have made every release ~50 minutes, defeating the reason for publishing images at all (fast, low-risk updates instead of a 25-minute rebuild on the user's NAS). Each architecture now builds on a runner of that architecture — ubuntu-latest and ubuntu-24.04-arm, free for public repos — and the results are joined into one multi-arch manifest. Per-arch jobs push BY DIGEST only; the version and latest tags are attached by the merge job once both architectures have succeeded. A tag therefore never points at a half-published set, which matters because the updater pulls by tag and rolls back on failure — a tag resolving to one architecture only would look like a successful pull and then fail to start on the other. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HmUiLHPmKoz215WAWV5eHe --- .github/workflows/release-images.yml | 144 ++++++++++++++++++++------- CHANGELOG.md | 22 ++++ VERSION | 2 +- 3 files changed, 129 insertions(+), 39 deletions(-) diff --git a/.github/workflows/release-images.yml b/.github/workflows/release-images.yml index ec56c54..1a90dc8 100644 --- a/.github/workflows/release-images.yml +++ b/.github/workflows/release-images.yml @@ -2,13 +2,17 @@ name: Publish release images # Auto-update pulls prebuilt images instead of rebuilding from source. That is # what makes an unattended 3am update safe: a from-source rebuild of the worker -# took ~25 minutes on a Synology NAS and wedged the Docker daemon once. Pulling a -# published layer takes seconds and needs no compiler on the user's box. +# image took ~25 minutes on a Synology NAS and wedged the Docker daemon once. +# +# BUILT ON NATIVE RUNNERS, NOT QEMU. The first version of this workflow used +# docker/setup-qemu-action to cross-build arm64. api and worker took 6 and 9 +# minutes; the Next.js web build took over 46 minutes under emulation and had to +# be abandoned — which would have made every release ~50 minutes and defeated the +# purpose. Each architecture now builds on its own native runner and the two are +# joined into one multi-arch manifest, so a release is a few minutes. # # Publishes: -# ghcr.io//waxflow-api: + :latest -# ghcr.io//waxflow-worker: + :latest -# ghcr.io//waxflow-web: + :latest +# ghcr.io//waxflow-{api,worker,web}: + :latest (amd64 + arm64) on: release: @@ -16,7 +20,7 @@ on: workflow_dispatch: inputs: version: - description: "Version to build and push (e.g. 2.11.0)" + description: "Version to build and push (e.g. 2.12.2)" required: true permissions: @@ -24,22 +28,79 @@ permissions: packages: write jobs: - publish: - runs-on: ubuntu-latest + # --------------------------------------------------------------------------- + # One job per (service, architecture), each on a runner of that architecture. + # Pushes by DIGEST only — the human-readable tags are attached by the merge job + # once both architectures exist, so a tag never points at a half-published set. + # --------------------------------------------------------------------------- + build: + runs-on: ${{ matrix.runner }} strategy: fail-fast: false matrix: + # Cross product: 3 services x 2 architectures = 6 jobs, each native. + service: [api, worker, web] + arch: [amd64, arm64] include: - - service: api - context: . - dockerfile: sync-api/Dockerfile - - service: worker - context: ./sync-worker - dockerfile: ./sync-worker/Dockerfile - - service: web - context: ./sync-web - dockerfile: ./sync-web/Dockerfile + - arch: amd64 + runner: ubuntu-latest + - arch: arm64 + runner: ubuntu-24.04-arm # free native arm64 runner for public repos + steps: + - uses: actions/checkout@v4 + + - name: Resolve build context + id: ctx + run: | + case "${{ matrix.service }}" in + api) echo "context=." >> "$GITHUB_OUTPUT"; echo "dockerfile=sync-api/Dockerfile" >> "$GITHUB_OUTPUT" ;; + worker) echo "context=./sync-worker" >> "$GITHUB_OUTPUT"; echo "dockerfile=./sync-worker/Dockerfile" >> "$GITHUB_OUTPUT" ;; + web) echo "context=./sync-web" >> "$GITHUB_OUTPUT"; echo "dockerfile=./sync-web/Dockerfile" >> "$GITHUB_OUTPUT" ;; + esac + echo "owner=${GITHUB_REPOSITORY_OWNER,,}" >> "$GITHUB_OUTPUT" + - uses: docker/setup-buildx-action@v3 + + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push by digest + id: build + uses: docker/build-push-action@v6 + with: + context: ${{ steps.ctx.outputs.context }} + file: ${{ steps.ctx.outputs.dockerfile }} + platforms: linux/${{ matrix.arch }} + build-args: | + GIT_SHA=${{ github.sha }} + outputs: type=image,name=ghcr.io/${{ steps.ctx.outputs.owner }}/waxflow-${{ matrix.service }},push-by-digest=true,name-canonical=true,push=true + cache-from: type=gha,scope=${{ matrix.service }}-${{ matrix.arch }} + cache-to: type=gha,mode=max,scope=${{ matrix.service }}-${{ matrix.arch }} + + - name: Export digest + run: | + mkdir -p /tmp/digests + echo "${{ steps.build.outputs.digest }}" > "/tmp/digests/${{ matrix.service }}-${{ matrix.arch }}" + + - uses: actions/upload-artifact@v4 + with: + name: digest-${{ matrix.service }}-${{ matrix.arch }} + path: /tmp/digests/${{ matrix.service }}-${{ matrix.arch }} + retention-days: 1 + + # --------------------------------------------------------------------------- + # Join the per-arch digests into one tagged manifest per service. + # --------------------------------------------------------------------------- + manifest: + needs: build + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + service: [api, worker, web] steps: - uses: actions/checkout@v4 @@ -51,9 +112,10 @@ jobs: else V="${GITHUB_REF_NAME#v}" fi - # VERSION in the repo is the source of truth; refuse a mismatched tag - # rather than publishing an image whose /app/VERSION disagrees with it. - FILE_V="$(cat VERSION | tr -d '[:space:]')" + # VERSION in the repo is the source of truth. Publishing an image whose + # baked /app/VERSION disagrees with its tag would make the update check + # oscillate forever, so refuse rather than ship it. + FILE_V="$(tr -d '[:space:]' < VERSION)" if [ "$V" != "$FILE_V" ]; then echo "::error::tag/input version '$V' != VERSION file '$FILE_V'" exit 1 @@ -61,8 +123,11 @@ jobs: echo "version=$V" >> "$GITHUB_OUTPUT" echo "owner=${GITHUB_REPOSITORY_OWNER,,}" >> "$GITHUB_OUTPUT" - - uses: docker/setup-qemu-action@v3 - - uses: docker/setup-buildx-action@v3 + - uses: actions/download-artifact@v4 + with: + pattern: digest-${{ matrix.service }}-* + merge-multiple: true + path: /tmp/digests - uses: docker/login-action@v3 with: @@ -70,19 +135,22 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Build and push - uses: docker/build-push-action@v6 - with: - context: ${{ matrix.context }} - file: ${{ matrix.dockerfile }} - # linux/amd64 covers Synology/Intel NAS boxes; arm64 covers Apple - # silicon and Raspberry Pi hosts. - platforms: linux/amd64,linux/arm64 - push: true - build-args: | - GIT_SHA=${{ github.sha }} - tags: | - ghcr.io/${{ steps.v.outputs.owner }}/waxflow-${{ matrix.service }}:${{ steps.v.outputs.version }} - ghcr.io/${{ steps.v.outputs.owner }}/waxflow-${{ matrix.service }}:latest - cache-from: type=gha,scope=${{ matrix.service }} - cache-to: type=gha,mode=max,scope=${{ matrix.service }} + - name: Create and push manifest + run: | + IMAGE="ghcr.io/${{ steps.v.outputs.owner }}/waxflow-${{ matrix.service }}" + REFS="" + for f in /tmp/digests/*; do + d="$(cat "$f")" + [ -n "$d" ] && REFS="$REFS ${IMAGE}@${d}" + done + if [ -z "$REFS" ]; then + echo "::error::no digests found for ${{ matrix.service }}" + exit 1 + fi + echo "joining:$REFS" + # shellcheck disable=SC2086 + docker buildx imagetools create \ + -t "${IMAGE}:${{ steps.v.outputs.version }}" \ + -t "${IMAGE}:latest" \ + $REFS + docker buildx imagetools inspect "${IMAGE}:${{ steps.v.outputs.version }}" diff --git a/CHANGELOG.md b/CHANGELOG.md index 2064dc4..8dc5f00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## 2.12.3 — build release images on native runners, not QEMU + +The first version of `release-images.yml` cross-built arm64 through +`docker/setup-qemu-action`. Measured on the 2.12.2 release: + +| image | build time | +|---|---| +| api | 6 min | +| worker | 9 min | +| **web (Next.js)** | **46+ min, abandoned** | + +Emulating a Node build is pathologically slow, and it would have made **every** +release take ~50 minutes — which defeats the point of publishing images so that +updates are fast. + +Each architecture now builds on a runner of that architecture (`ubuntu-latest` +and `ubuntu-24.04-arm`, free for public repos) and the two are joined into one +multi-arch manifest. Per-arch jobs push **by digest only**; the human-readable +tags are attached by the merge job once both architectures exist, so a tag never +points at a half-published set. + + ## 2.12.2 — fix: WaxFlow could not be built from a fresh clone `sync-web/public/` was never committed, but `sync-web/Dockerfile` COPYs it out of diff --git a/VERSION b/VERSION index 371a952..ccc99d0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.12.2 +2.12.3