Skip to content

Commit 49a20ed

Browse files
committed
split arm64 build: Docker on Linux, QEMU snapshot on macOS
Docker is difficult to run on macOS CI runners (colima VZ and QEMU backends both crash). Split into two stages: 1. docker-build (Linux): builds arm64 Docker image, exports tarball 2. qemu-snapshot (macOS): provisions QEMU VM under HVF, captures snapshot Add SKIP_DOCKER_BUILD=1 to build-image.sh to reuse a pre-built bundle.
1 parent 54ecda8 commit 49a20ed

2 files changed

Lines changed: 88 additions & 37 deletions

File tree

.github/workflows/qemu-emulator-build-arm64.yaml

Lines changed: 82 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
name: Build QEMU Emulator Image (arm64 / macOS)
22

3-
# arm64 emulator images are built on a macOS Apple Silicon runner so the
4-
# snapshot is captured under HVF — the same accelerator developer Macs use.
5-
# KVM snapshots (from Linux runners) are NOT resumable under HVF because
6-
# `-cpu max` expands to different feature sets under each accelerator.
3+
# arm64 emulator images are built in two stages:
4+
# 1. docker-build (Linux): builds the Docker container image for arm64 and
5+
# exports a tarball — Docker is painful to run on macOS CI runners.
6+
# 2. qemu-snapshot (macOS): boots the image under HVF on Apple Silicon,
7+
# provisions it, and captures a snapshot. HVF snapshots are portable to
8+
# developer Macs; KVM snapshots are NOT (differing -cpu max features).
79

810
on:
911
push:
@@ -22,14 +24,68 @@ concurrency:
2224

2325
env:
2426
EMULATOR_IMAGE_NAME: stack-local-emulator
25-
EMULATOR_IMAGE_DIR: ${{ github.workspace }}/docker/local-emulator/qemu/images
26-
EMULATOR_RUN_DIR: ${{ github.workspace }}/docker/local-emulator/qemu/run
2727

2828
jobs:
29-
build:
30-
name: Build QEMU Image (arm64)
29+
# ---------- Stage 1: build Docker image on Linux ----------
30+
docker-build:
31+
name: Build Docker Image (arm64)
32+
runs-on: ubicloud-standard-8
33+
timeout-minutes: 60
34+
35+
steps:
36+
- uses: actions/checkout@v6
37+
38+
- name: Set up QEMU user-mode emulation
39+
uses: docker/setup-qemu-action@v3
40+
41+
- name: Set up Docker Buildx
42+
uses: docker/setup-buildx-action@v3
43+
44+
- uses: pnpm/action-setup@v4
45+
with:
46+
version: 10.23.0
47+
48+
- uses: actions/setup-node@v4
49+
with:
50+
node-version: 22
51+
cache: pnpm
52+
53+
- name: Generate emulator env
54+
run: node docker/local-emulator/generate-env-development.mjs
55+
56+
- name: Build arm64 Docker image
57+
run: |
58+
docker buildx build \
59+
--platform linux/arm64 \
60+
--tag "$EMULATOR_IMAGE_NAME" \
61+
--load \
62+
-f docker/local-emulator/Dockerfile \
63+
.
64+
65+
- name: Export Docker image bundle
66+
run: |
67+
mkdir -p /tmp/bundle
68+
docker save "$EMULATOR_IMAGE_NAME" | gzip -c > /tmp/bundle/emulator-arm64-docker-images.tar.gz
69+
docker image inspect --format '{{.ID}}' "$EMULATOR_IMAGE_NAME" > /tmp/bundle/emulator-arm64-docker-images.tar.gz.image-ids
70+
ls -lh /tmp/bundle/
71+
72+
- name: Upload Docker bundle
73+
uses: actions/upload-artifact@v4
74+
with:
75+
name: arm64-docker-bundle
76+
path: /tmp/bundle/
77+
retention-days: 1
78+
compression-level: 0
79+
80+
# ---------- Stage 2: QEMU provision + snapshot on macOS (HVF) ----------
81+
qemu-snapshot:
82+
name: QEMU Snapshot (arm64 / HVF)
83+
needs: docker-build
3184
runs-on: macos-15
3285
timeout-minutes: 120
86+
env:
87+
EMULATOR_IMAGE_DIR: ${{ github.workspace }}/docker/local-emulator/qemu/images
88+
EMULATOR_RUN_DIR: ${{ github.workspace }}/docker/local-emulator/qemu/run
3389

3490
steps:
3591
- uses: actions/checkout@v6
@@ -46,17 +102,6 @@ jobs:
46102
- name: Install system dependencies
47103
run: brew install qemu socat zstd
48104

49-
- name: Set up Docker via colima
50-
run: |
51-
brew install docker docker-buildx colima
52-
# Wire up buildx as a CLI plugin
53-
mkdir -p ~/.docker
54-
echo '{"cliPluginsExtraDirs":["/opt/homebrew/lib/docker/cli-plugins"]}' > ~/.docker/config.json
55-
# VZ driver doesn't work on GHA macOS runners — use QEMU backend
56-
colima start --vm-type=qemu --cpu 4 --memory 6 --disk 60 --arch aarch64
57-
docker info
58-
docker buildx version
59-
60105
- name: Verify QEMU + HVF
61106
run: |
62107
qemu-system-aarch64 --version
@@ -67,17 +112,26 @@ jobs:
67112
exit 1
68113
fi
69114
70-
- name: Build QEMU image
115+
- name: Download Docker bundle
116+
uses: actions/download-artifact@v4
117+
with:
118+
name: arm64-docker-bundle
119+
path: ${{ env.EMULATOR_IMAGE_DIR }}/
120+
121+
- name: Generate emulator env
122+
run: node docker/local-emulator/generate-env-development.mjs
123+
124+
- name: Build QEMU image (provision + snapshot)
71125
run: |
72126
chmod +x docker/local-emulator/qemu/build-image.sh
127+
# SKIP_DOCKER_BUILD=1 tells build-image.sh to skip the Docker
128+
# build + export steps — we already have the bundle from stage 1.
73129
EMULATOR_PROVISION_TIMEOUT=6000 \
130+
SKIP_DOCKER_BUILD=1 \
74131
docker/local-emulator/qemu/build-image.sh arm64
75132
76-
- name: Generate emulator env
77-
run: node docker/local-emulator/generate-env-development.mjs
78-
79-
# HVF gives us native-speed arm64 — we can verify the image boots
80-
# and services come up, unlike the old cross-arch TCG path.
133+
# HVF gives us native-speed arm64 — verify the image boots and
134+
# services come up (previously impossible under cross-arch TCG).
81135
- name: Build stack-cli
82136
run: |
83137
pnpm install --frozen-lockfile --filter '@stackframe/stack-cli...'
@@ -87,34 +141,27 @@ jobs:
87141
env:
88142
EMULATOR_ARCH: arm64
89143
EMULATOR_READY_TIMEOUT: 3200
90-
EMULATOR_IMAGE_DIR: ${{ env.EMULATOR_IMAGE_DIR }}
91-
EMULATOR_RUN_DIR: ${{ env.EMULATOR_RUN_DIR }}
92144
run: node packages/stack-cli/dist/index.js emulator start
93145

94146
- name: Verify services are healthy
95147
env:
96148
EMULATOR_ARCH: arm64
97-
EMULATOR_IMAGE_DIR: ${{ env.EMULATOR_IMAGE_DIR }}
98-
EMULATOR_RUN_DIR: ${{ env.EMULATOR_RUN_DIR }}
99149
run: node packages/stack-cli/dist/index.js emulator status
100150

101151
- name: Stop emulator
102152
if: always()
103153
env:
104154
EMULATOR_ARCH: arm64
105-
EMULATOR_IMAGE_DIR: ${{ env.EMULATOR_IMAGE_DIR }}
106-
EMULATOR_RUN_DIR: ${{ env.EMULATOR_RUN_DIR }}
107155
run: node packages/stack-cli/dist/index.js emulator stop
108156

109157
- name: Print serial log on failure
110158
if: failure()
111-
run: |
112-
tail -100 "$EMULATOR_RUN_DIR/vm/serial.log" 2>/dev/null || true
159+
run: tail -100 "$EMULATOR_RUN_DIR/vm/serial.log" 2>/dev/null || true
113160

114161
- name: Package image
115162
run: |
116-
BASE_IMG="docker/local-emulator/qemu/images/stack-emulator-arm64.qcow2"
117-
SAVEVM="docker/local-emulator/qemu/images/stack-emulator-arm64.savevm.zst"
163+
BASE_IMG="$EMULATOR_IMAGE_DIR/stack-emulator-arm64.qcow2"
164+
SAVEVM="$EMULATOR_IMAGE_DIR/stack-emulator-arm64.savevm.zst"
118165
cp "$BASE_IMG" "stack-emulator-arm64.qcow2"
119166
if [ -f "$SAVEVM" ]; then
120167
cp "$SAVEVM" "stack-emulator-arm64.savevm.zst"

docker/local-emulator/qemu/build-image.sh

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -657,8 +657,12 @@ BUILD_ENV_FILE="$REPO_ROOT/docker/local-emulator/.env.development"
657657
for arch in "${TARGET_ARCHS[@]}"; do
658658
local_base="$IMAGE_DIR/debian-${DEBIAN_VERSION}-base-${arch}.qcow2"
659659
download_cloud_image "$arch" "$local_base"
660-
build_local_emulator_image "$arch"
661-
prepare_bundle_artifacts "$arch"
660+
if [ "${SKIP_DOCKER_BUILD:-0}" = "1" ]; then
661+
log "SKIP_DOCKER_BUILD=1: reusing pre-built Docker bundle"
662+
else
663+
build_local_emulator_image "$arch"
664+
prepare_bundle_artifacts "$arch"
665+
fi
662666
build_one "$arch"
663667
done
664668

0 commit comments

Comments
 (0)