From e3b0f4510c5420ee4fc17c9a67096cba770ae83f Mon Sep 17 00:00:00 2001 From: Perfect Makanju Date: Fri, 6 Nov 2026 10:21:02 +0000 Subject: [PATCH 1/2] ci: build each published platform on a native runner The arm64 half of the image was built under QEMU on one x64 runner and took about 24 of a publish run's 25 minutes. Each platform now builds natively on its own runner and pushes by digest, and a final job joins the two into the tagged manifest list. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/publish.yml | 151 ++++++++++++++++++++++++++++------ 1 file changed, 127 insertions(+), 24 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index fc87432c7..f2e3d35ac 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -43,11 +43,13 @@ env: IMAGE: us-docker.pkg.dev/e2b-artifacts/dashboard/dashboard jobs: - publish: - name: Build and push the image + preflight: + name: Check the release runs-on: ubuntu-latest - # The arm64 build runs emulated; allow for it. - timeout-minutes: 90 + timeout-minutes: 10 + outputs: + tag: ${{ steps.meta.outputs.tag }} + sha: ${{ steps.meta.outputs.sha }} steps: - name: Check the publishing credentials are configured @@ -152,48 +154,149 @@ jobs: exit 1 fi - # The arm64 half of the manifest list runs its Bun install and Next build - # under emulation; the alternative, cross-building on amd64, would ship - # amd64-only native modules (sharp) inside the standalone output. - - name: Set up QEMU - uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4.3.0 + # Each platform builds on a native runner. Emulating arm64 under QEMU made its + # Next build roughly ten times slower, and cross-building from amd64 would + # ship amd64-only native modules (sharp) inside the standalone output. + build: + name: Build ${{ matrix.platform }} + needs: preflight + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + strategy: + matrix: + include: + - platform: linux/amd64 + arch: amd64 + runner: ubuntu-24.04 + - platform: linux/arm64 + arch: arm64 + runner: ubuntu-24.04-arm + + steps: + - name: Checkout code + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + ref: ${{ needs.preflight.outputs.sha }} + + - name: Authenticate to Google Cloud + if: ${{ inputs.dry_run != true }} + uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3.0.0 + with: + workload_identity_provider: ${{ vars.GCP_WORKLOAD_IDENTITY_PROVIDER }} + service_account: ${{ vars.GCP_SERVICE_ACCOUNT }} + + - name: Set up gcloud + if: ${{ inputs.dry_run != true }} + uses: google-github-actions/setup-gcloud@aa5489c8933f4cc7a4f7d45035b3b1440c9c10db # v3.0.1 + + - name: Configure Docker for Artifact Registry + if: ${{ inputs.dry_run != true }} + run: gcloud auth configure-docker us-docker.pkg.dev --quiet - name: Set up Buildx uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - - name: Build and push + - name: Build env: - TAG: ${{ steps.meta.outputs.tag }} - SHA: ${{ steps.meta.outputs.sha }} + PLATFORM: ${{ matrix.platform }} + TAG: ${{ needs.preflight.outputs.tag }} + SHA: ${{ needs.preflight.outputs.sha }} DRY_RUN: ${{ inputs.dry_run }} run: | set -euo pipefail - output=--push - platforms=linux/amd64,linux/arm64 + # The image is pushed untagged; the merge job names it once both + # platforms exist, so a failed leg never leaves a half-published tag. + output="type=image,name=${IMAGE},push-by-digest=true,name-canonical=true,push=true" if [ "${DRY_RUN}" = "true" ]; then - # A dry run loads the image into the local daemon, which can hold - # one platform, so it rehearses the native build only. - output=--load - platforms=linux/amd64 + output=type=docker fi # Artifact Registry rejects buildx's default provenance/SBOM # attestation manifest lists with HTTP 400. docker buildx build \ - --platform "${platforms}" \ + --platform "${PLATFORM}" \ --provenance=false \ --sbom=false \ --build-arg "BUILD=${TAG}" \ --label "org.opencontainers.image.revision=${SHA}" \ --label "org.opencontainers.image.version=${TAG}" \ --label "org.opencontainers.image.source=https://github.com/${GITHUB_REPOSITORY}" \ - --tag "${IMAGE}:${TAG}" \ - "${output}" . + --metadata-file "${RUNNER_TEMP}/metadata.json" \ + --output "${output}" \ + . - - name: Record what was published + - name: Record the digest if: ${{ inputs.dry_run != true }} env: - TAG: ${{ steps.meta.outputs.tag }} - SHA: ${{ steps.meta.outputs.sha }} + ARCH: ${{ matrix.arch }} + run: | + set -euo pipefail + digest="$(jq -er '."containerimage.digest"' "${RUNNER_TEMP}/metadata.json")" + mkdir -p "${RUNNER_TEMP}/digests" + printf '%s' "${digest}" > "${RUNNER_TEMP}/digests/${ARCH}" + + - name: Upload the digest + if: ${{ inputs.dry_run != true }} + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: digest-${{ matrix.arch }} + path: ${{ runner.temp }}/digests/${{ matrix.arch }} + if-no-files-found: error + retention-days: 1 + + merge: + name: Publish the manifest list + needs: [preflight, build] + if: ${{ inputs.dry_run != true }} + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Download the digests + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + pattern: digest-* + path: ${{ runner.temp }}/digests + merge-multiple: true + + - name: Authenticate to Google Cloud + uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3.0.0 + with: + workload_identity_provider: ${{ vars.GCP_WORKLOAD_IDENTITY_PROVIDER }} + service_account: ${{ vars.GCP_SERVICE_ACCOUNT }} + + - name: Set up gcloud + uses: google-github-actions/setup-gcloud@aa5489c8933f4cc7a4f7d45035b3b1440c9c10db # v3.0.1 + + - name: Configure Docker for Artifact Registry + run: gcloud auth configure-docker us-docker.pkg.dev --quiet + + - name: Set up Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + + - name: Create the manifest list + env: + TAG: ${{ needs.preflight.outputs.tag }} + run: | + set -euo pipefail + amd64="$(cat "${RUNNER_TEMP}/digests/amd64")" + arm64="$(cat "${RUNNER_TEMP}/digests/arm64")" + sources=("${IMAGE}@${amd64}" "${IMAGE}@${arm64}") + # Tags are immutable, so the list is checked before it is pushed: a + # wrong one could never be replaced under the same tag. + planned="$(docker buildx imagetools create --dry-run "${sources[@]}" \ + | jq -r '.manifests[] | "\(.platform.os)/\(.platform.architecture) \(.digest)"' | sort)" + expected="$(printf 'linux/amd64 %s\nlinux/arm64 %s' "${amd64}" "${arm64}")" + if [ "${planned}" != "${expected}" ]; then + printf 'planned:\n%s\nexpected:\n%s\n' "${planned}" "${expected}" >&2 + echo "FIX: the manifest list must hold exactly linux/amd64 and linux/arm64 from this run's builds; check the build job's digests" >&2 + exit 1 + fi + docker buildx imagetools create --tag "${IMAGE}:${TAG}" "${sources[@]}" + + - name: Record what was published + env: + TAG: ${{ needs.preflight.outputs.tag }} + SHA: ${{ needs.preflight.outputs.sha }} run: | set -euo pipefail digest="$(docker buildx imagetools inspect "${IMAGE}:${TAG}" --format '{{json .Manifest.Digest}}' | tr -d '"')" From bed90a59b44bae0990a6b76cb8eb30129c87b437 Mon Sep 17 00:00:00 2001 From: Perfect Makanju Date: Fri, 6 Nov 2026 10:27:42 +0000 Subject: [PATCH 2/2] ci: guard the native publish build in the workflow test The publish workflow test still required the QEMU action and a single multi-platform build. It now checks for native per-architecture runners, push by digest, a manifest list verified before it is tagged, and a dry run that never reaches the merge job. Co-Authored-By: Claude Opus 5.5 --- tests/unit/publish-workflow.test.ts | 31 +++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/tests/unit/publish-workflow.test.ts b/tests/unit/publish-workflow.test.ts index 972bedee3..374cd98a4 100644 --- a/tests/unit/publish-workflow.test.ts +++ b/tests/unit/publish-workflow.test.ts @@ -33,8 +33,35 @@ describe('publish workflow', () => { expect(workflow).toContain( 'us-docker.pkg.dev/e2b-artifacts/dashboard/dashboard' ) - expect(workflow).toContain('platforms=linux/amd64,linux/arm64') - expect(workflow).toContain('docker/setup-qemu-action@') + expect(workflow).toContain('push-by-digest=true') + expect(workflow).toContain('docker buildx imagetools create') + }) + + it('builds each architecture natively, since cross-builds ship the wrong sharp', () => { + expect(workflow).toMatch( + /platform: linux\/amd64\s*\n\s*arch: amd64\s*\n\s*runner: ubuntu-24\.04\n/ + ) + expect(workflow).toMatch( + /platform: linux\/arm64\s*\n\s*arch: arm64\s*\n\s*runner: ubuntu-24\.04-arm\n/ + ) + expect(workflow).not.toContain('docker/setup-qemu-action@') + expect(workflow).toContain('ref: ${{ needs.preflight.outputs.sha }}') + }) + + it('tags only a manifest list holding both architectures', () => { + expect(workflow).toContain( + "expected=\"$(printf 'linux/amd64 %s\\nlinux/arm64 %s'" + ) + expect(workflow.indexOf('imagetools create --dry-run')).toBeLessThan( + workflow.indexOf('imagetools create --tag') + ) + }) + + it('never publishes from a dry run', () => { + expect(workflow).toMatch( + /merge:\n(?: {4}\S.*\n)*? {4}if: \$\{\{ inputs\.dry_run != true \}\}\n/ + ) + expect(workflow).toContain('output=type=docker') }) it('disables the attestations Artifact Registry rejects', () => {