Skip to content

Fast-start local emulator via RAM snapshot + live secret rotation #4

Fast-start local emulator via RAM snapshot + live secret rotation

Fast-start local emulator via RAM snapshot + live secret rotation #4

name: Build QEMU Emulator Image (arm64 / macOS)
# arm64 emulator images are built in two stages:
# 1. docker-build (Linux): builds the Docker container image for arm64 and
# exports a tarball — Docker is painful to run on macOS CI runners.
# 2. qemu-snapshot (macOS): boots the image under HVF on Apple Silicon,
# provisions it, and captures a snapshot. HVF snapshots are portable to
# developer Macs; KVM snapshots are NOT (differing -cpu max features).
on:
push:
branches:
- main
- dev
pull_request:
paths:
- 'docker/local-emulator/**'
- '.github/workflows/qemu-emulator-build-arm64.yaml'
workflow_dispatch:
concurrency:
group: qemu-arm64-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
env:
EMULATOR_IMAGE_NAME: stack-local-emulator
jobs:
# ---------- Stage 1: build Docker image on Linux ----------
docker-build:
name: Build Docker Image (arm64)
runs-on: ubicloud-standard-8
timeout-minutes: 60
steps:
- uses: actions/checkout@v6
- name: Set up QEMU user-mode emulation
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- uses: pnpm/action-setup@v4
with:
version: 10.23.0
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Generate emulator env
run: node docker/local-emulator/generate-env-development.mjs
- name: Build arm64 Docker image
run: |
docker buildx build \
--platform linux/arm64 \
--tag "$EMULATOR_IMAGE_NAME" \
--load \
-f docker/local-emulator/Dockerfile \
.
- name: Export Docker image bundle
run: |
mkdir -p /tmp/bundle
docker save "$EMULATOR_IMAGE_NAME" | gzip -c > /tmp/bundle/emulator-arm64-docker-images.tar.gz
docker image inspect --format '{{.ID}}' "$EMULATOR_IMAGE_NAME" > /tmp/bundle/emulator-arm64-docker-images.tar.gz.image-ids
ls -lh /tmp/bundle/
- name: Upload Docker bundle
uses: actions/upload-artifact@v4
with:
name: arm64-docker-bundle
path: /tmp/bundle/
retention-days: 1
compression-level: 0
# ---------- Stage 2: QEMU provision + snapshot on macOS (HVF) ----------
qemu-snapshot:
name: QEMU Snapshot (arm64 / HVF)
needs: docker-build
runs-on: macos-15
timeout-minutes: 120
env:
EMULATOR_IMAGE_DIR: ${{ github.workspace }}/docker/local-emulator/qemu/images
EMULATOR_RUN_DIR: ${{ github.workspace }}/docker/local-emulator/qemu/run
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
with:
version: 10.23.0
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install system dependencies
run: brew install qemu socat zstd
- name: Verify QEMU + HVF
run: |
qemu-system-aarch64 --version
if qemu-system-aarch64 -accel help 2>&1 | grep -q hvf; then
echo "HVF available — snapshot will be portable to developer Macs"
else
echo "::error::HVF not available on this runner"
exit 1
fi
- name: Download Docker bundle
uses: actions/download-artifact@v4
with:
name: arm64-docker-bundle
path: ${{ env.EMULATOR_IMAGE_DIR }}/
- name: Generate emulator env
run: node docker/local-emulator/generate-env-development.mjs
- name: Build QEMU image (provision + snapshot)
run: |
chmod +x docker/local-emulator/qemu/build-image.sh
# SKIP_DOCKER_BUILD=1 tells build-image.sh to skip the Docker
# build + export steps — we already have the bundle from stage 1.
EMULATOR_PROVISION_TIMEOUT=6000 \
SKIP_DOCKER_BUILD=1 \
docker/local-emulator/qemu/build-image.sh arm64
# HVF gives us native-speed arm64 — verify the image boots and
# services come up (previously impossible under cross-arch TCG).
- name: Build stack-cli
run: |
pnpm install --frozen-lockfile --filter '@stackframe/stack-cli...'
pnpm exec turbo run build --filter='@stackframe/stack-cli...'
- name: Start emulator and verify
env:
EMULATOR_ARCH: arm64
EMULATOR_READY_TIMEOUT: 3200
run: node packages/stack-cli/dist/index.js emulator start
- name: Verify services are healthy
env:
EMULATOR_ARCH: arm64
run: node packages/stack-cli/dist/index.js emulator status
- name: Stop emulator
if: always()
env:
EMULATOR_ARCH: arm64
run: node packages/stack-cli/dist/index.js emulator stop
- name: Print serial log on failure
if: failure()
run: tail -100 "$EMULATOR_RUN_DIR/vm/serial.log" 2>/dev/null || true
- name: Package image
run: |
BASE_IMG="$EMULATOR_IMAGE_DIR/stack-emulator-arm64.qcow2"
SAVEVM="$EMULATOR_IMAGE_DIR/stack-emulator-arm64.savevm.zst"
cp "$BASE_IMG" "stack-emulator-arm64.qcow2"
if [ -f "$SAVEVM" ]; then
cp "$SAVEVM" "stack-emulator-arm64.savevm.zst"
ls -lh "stack-emulator-arm64.savevm.zst"
else
echo "::error::Snapshot was not produced — fast-start will be unavailable"
exit 1
fi
- name: Upload image artifact
uses: actions/upload-artifact@v4
with:
name: qemu-emulator-arm64
path: |
stack-emulator-arm64.qcow2
stack-emulator-arm64.savevm.zst
if-no-files-found: error
retention-days: 30
compression-level: 0