Fast-start local emulator via RAM snapshot + live secret rotation #4
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |