Skip to content

Commit 9ea0744

Browse files
jabenedicicclaude
andauthored
ci(build): cross-build per arch on native runners (#133)
Replace QEMU-based multi-arch image build with a matrix that runs on native amd64 and arm64 GitHub-hosted runners, then composes the multi-arch manifest with `docker buildx imagetools create`. This re-enables linux/arm64 publishing (previously disabled) without paying QEMU's emulation overhead and reliability cost. The Dockerfile drops its in-container Go builder stage; each runner cross-compiles the binary natively into dist/${TARGETOS}-${TARGETARCH}/ and the runtime image just copies it in. ca-certificates is retained: arcade is an outbound HTTPS client across teranode, merkle service, datahub, and webhook delivery, and TLS handshakes need the system CA bundle. A `make docker-build` target preserves a one-shot local build flow that mirrors the CI layout. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent bc96604 commit 9ea0744

4 files changed

Lines changed: 166 additions & 30 deletions

File tree

.dockerignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ vendor/
2121

2222
# Binaries for programs and plugins
2323
dist/
24-
!dist/linux/
24+
!dist/linux-amd64/
25+
!dist/linux-arm64/
2526
gin-bin
2627
*.exe
2728
*.exe~

.github/workflows/build.yml

Lines changed: 136 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,14 @@ name: Build and Push to GHCR
1717
# - workflow_dispatch: manual run; gated via `needs:` chain on local jobs that
1818
# re-verify the head SHA passed GoFortress before publishing.
1919
# ------------------------------------------------------------------------------------
20+
# Multi-arch strategy
21+
# ------------------------------------------------------------------------------------
22+
# Each architecture is built on its native runner (`ubuntu-24.04` for amd64,
23+
# `ubuntu-24.04-arm` for arm64) — no QEMU emulation. Each matrix job pushes a
24+
# single-arch image to GHCR by digest only (no tag). The merge-manifest job then
25+
# composes those digests into a single multi-arch manifest list under the existing
26+
# tag scheme (`<sha>` and `latest` / `<git-tag>`).
27+
# ------------------------------------------------------------------------------------
2028

2129
on:
2230
workflow_run:
@@ -31,6 +39,10 @@ on:
3139

3240
permissions: {}
3341

42+
env:
43+
REGISTRY: ghcr.io
44+
IMAGE_NAME: bsv-blockchain/arcade
45+
3446
jobs:
3547
# --------------------------------------------------------------------------------
3648
# Gate: ensure the upstream GoFortress run (lint, security, tests) succeeded.
@@ -85,15 +97,26 @@ jobs:
8597
outputs:
8698
deployment_tag: ${{ steps.deployment_tag.outputs.id }}
8799

100+
# --------------------------------------------------------------------------------
101+
# Per-arch native build. Each matrix entry runs on a native runner of its target
102+
# architecture, cross-compiles the Go binary into dist/linux-<arch>/arcade, then
103+
# uses buildx to assemble a single-arch image from the thin runtime Dockerfile
104+
# and pushes it to GHCR by digest only. PRs build for verification but don't push.
105+
# --------------------------------------------------------------------------------
88106
build-and-push:
89-
# Gate the publishing step on:
90-
# - get_tag (computes the image tag)
91-
# - gofortress-gate (ensures lint/security/tests passed upstream)
92107
needs: [get_tag, gofortress-gate]
93-
runs-on: ubuntu-latest
94108
permissions:
95109
contents: read
96110
packages: write
111+
strategy:
112+
fail-fast: true
113+
matrix:
114+
include:
115+
- arch: amd64
116+
runner: ubuntu-24.04
117+
- arch: arm64
118+
runner: ubuntu-24.04-arm
119+
runs-on: ${{ matrix.runner }}
97120
steps:
98121
# Step 1: checkout code. For workflow_run events, check out the exact SHA
99122
# that GoFortress validated, not whatever HEAD happens to be on main now.
@@ -102,31 +125,124 @@ jobs:
102125
with:
103126
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
104127

105-
# Step 2: Set up QEMU for multi-architecture builds
106-
- name: Set up QEMU
107-
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
128+
# Step 2: install Go and warm the module / build cache for this runner.
129+
- name: Set up Go
130+
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
131+
with:
132+
go-version-file: go.mod
133+
cache: true
108134

109-
# Step 3: Set up Docker Buildx
135+
# Step 3: build the binary natively into the dist layout the Dockerfile
136+
# expects. GOOS/GOARCH are explicit so the path matches even if a future
137+
# runner image defaults differ.
138+
- name: Build arcade binary
139+
env:
140+
CGO_ENABLED: '0'
141+
GOOS: linux
142+
GOARCH: ${{ matrix.arch }}
143+
run: |
144+
mkdir -p dist/linux-${{ matrix.arch }}
145+
go build -trimpath -ldflags="-s -w" -o dist/linux-${{ matrix.arch }}/arcade ./cmd/arcade
146+
147+
# Step 4: Set up Docker Buildx (no QEMU needed — single-platform build on
148+
# a native runner of that platform).
110149
- name: Set up Docker Buildx
111150
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
112151

113-
# Step 4: login to GCHR
152+
# Step 5: login to GHCR (skipped on PRs since we don't push).
114153
- name: Log in to GitHub Container Registry
154+
if: github.event_name != 'pull_request'
115155
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
116156
with:
117-
registry: ghcr.io
157+
registry: ${{ env.REGISTRY }}
118158
username: ${{ github.actor }}
119159
password: ${{ secrets.GITHUB_TOKEN }}
120160

121-
# Step 5: Build and push the Docker image
122-
# Publishing is allowed only for non-PR events; PRs build for verification.
123-
- name: Build and push Docker image
161+
# Step 6a: PR build verifies the Dockerfile assembles for this arch but
162+
# never pushes anywhere.
163+
- name: Build image (PR verification, no push)
164+
if: github.event_name == 'pull_request'
124165
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
125166
with:
126-
context: . # Build context (root directory, adjust if Dockerfile is elsewhere)
127-
file: ./Dockerfile # Path to Dockerfile
128-
platforms: linux/amd64 #, linux/arm64 Disable ARM for now
129-
push: ${{ github.event_name != 'pull_request' }} # Only push on non-PR events
130-
tags: |
131-
ghcr.io/bsv-blockchain/arcade:${{ github.event.workflow_run.head_sha || github.sha }}
132-
ghcr.io/bsv-blockchain/arcade:${{ needs.get_tag.outputs.deployment_tag }}
167+
context: .
168+
file: ./Dockerfile
169+
platforms: linux/${{ matrix.arch }}
170+
push: false
171+
172+
# Step 6b: non-PR build pushes by digest only (no tag). The merge-manifest
173+
# job stitches per-arch digests into the final multi-arch tag.
174+
- name: Build and push image by digest
175+
if: github.event_name != 'pull_request'
176+
id: build
177+
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
178+
with:
179+
context: .
180+
file: ./Dockerfile
181+
platforms: linux/${{ matrix.arch }}
182+
outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
183+
184+
# Step 7: write the digest to a file named after itself so the merge job
185+
# can reconstruct the per-arch image references.
186+
- name: Export digest
187+
if: github.event_name != 'pull_request'
188+
env:
189+
DIGEST: ${{ steps.build.outputs.digest }}
190+
run: |
191+
mkdir -p "${RUNNER_TEMP}/digests"
192+
touch "${RUNNER_TEMP}/digests/${DIGEST#sha256:}"
193+
194+
- name: Upload digest artifact
195+
if: github.event_name != 'pull_request'
196+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
197+
with:
198+
name: digests-${{ matrix.arch }}
199+
path: ${{ runner.temp }}/digests/*
200+
if-no-files-found: error
201+
retention-days: 1
202+
203+
# --------------------------------------------------------------------------------
204+
# Compose the per-arch digests into a single multi-arch manifest tagged
205+
# :<sha> and :<deployment_tag>. Skipped on PRs (no digests produced).
206+
# --------------------------------------------------------------------------------
207+
merge-manifest:
208+
needs: [build-and-push, get_tag]
209+
if: github.event_name != 'pull_request'
210+
runs-on: ubuntu-latest
211+
permissions:
212+
contents: read
213+
packages: write
214+
steps:
215+
- name: Download digest artifacts
216+
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
217+
with:
218+
path: ${{ runner.temp }}/digests
219+
pattern: digests-*
220+
merge-multiple: true
221+
222+
- name: Set up Docker Buildx
223+
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
224+
225+
- name: Log in to GitHub Container Registry
226+
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
227+
with:
228+
registry: ${{ env.REGISTRY }}
229+
username: ${{ github.actor }}
230+
password: ${{ secrets.GITHUB_TOKEN }}
231+
232+
- name: Compose and push multi-arch manifest
233+
working-directory: ${{ runner.temp }}/digests
234+
env:
235+
IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
236+
SHA_TAG: ${{ github.event.workflow_run.head_sha || github.sha }}
237+
DEPLOYMENT_TAG: ${{ needs.get_tag.outputs.deployment_tag }}
238+
run: |
239+
docker buildx imagetools create \
240+
--tag "${IMAGE}:${SHA_TAG}" \
241+
--tag "${IMAGE}:${DEPLOYMENT_TAG}" \
242+
$(printf "${IMAGE}@sha256:%s " *)
243+
244+
- name: Inspect resulting manifest
245+
env:
246+
IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
247+
SHA_TAG: ${{ github.event.workflow_run.head_sha || github.sha }}
248+
run: docker buildx imagetools inspect "${IMAGE}:${SHA_TAG}"

Dockerfile

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,21 @@
1-
FROM golang:1.26-alpine AS builder
2-
3-
WORKDIR /app
4-
COPY go.mod go.sum ./
5-
RUN go mod download
6-
COPY . .
7-
RUN CGO_ENABLED=0 GOOS=linux go build -o /arcade ./cmd/arcade
1+
# syntax=docker/dockerfile:1
2+
#
3+
# Runtime-only image. The arcade binary is cross-compiled in CI on a native
4+
# runner per architecture and copied in here, so this Dockerfile contains no
5+
# Go toolchain and no cross-compilation. To build locally, run `make
6+
# docker-build` (which produces the dist/linux-<arch>/arcade layout this
7+
# Dockerfile expects).
8+
#
9+
# ca-certificates is required: arcade is an outbound HTTPS client (Teranode,
10+
# merkle service, datahub, webhook delivery) and TLS handshakes fail without
11+
# the system CA bundle.
812

913
FROM alpine:3.23
14+
1015
RUN apk --no-cache add ca-certificates
11-
COPY --from=builder /arcade /usr/local/bin/arcade
16+
17+
ARG TARGETOS
18+
ARG TARGETARCH
19+
COPY dist/${TARGETOS}-${TARGETARCH}/arcade /usr/local/bin/arcade
20+
1221
ENTRYPOINT ["arcade"]

Makefile

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
.PHONY: build test lint docker-up docker-down run
1+
.PHONY: build test lint docker-up docker-down docker-build run
2+
3+
GOARCH ?= $(shell go env GOARCH)
24

35
build:
46
go build ./...
@@ -15,5 +17,13 @@ docker-up:
1517
docker-down:
1618
podman-compose down
1719

20+
# Build a single-arch image for local use that matches the CI layout. The
21+
# Dockerfile expects a binary at dist/linux-<arch>/arcade; this target produces
22+
# that layout for the host's architecture and tags the image arcade:local.
23+
docker-build:
24+
mkdir -p dist/linux-$(GOARCH)
25+
CGO_ENABLED=0 GOOS=linux GOARCH=$(GOARCH) go build -trimpath -ldflags="-s -w" -o dist/linux-$(GOARCH)/arcade ./cmd/arcade
26+
docker build --platform=linux/$(GOARCH) -t arcade:local .
27+
1828
run:
1929
go run ./cmd/arcade

0 commit comments

Comments
 (0)