diff --git a/.github/workflows/qemu-emulator-build-arm64.yaml b/.github/workflows/qemu-emulator-build-arm64.yaml new file mode 100644 index 000000000..c58e06d40 --- /dev/null +++ b/.github/workflows/qemu-emulator-build-arm64.yaml @@ -0,0 +1,134 @@ +name: Build QEMU Emulator Image (arm64 / macOS) + +# arm64 emulator images are built on a macOS Apple Silicon runner so the +# snapshot is captured under HVF — the same accelerator developer Macs use. +# KVM snapshots (from Linux runners) are NOT resumable under HVF because +# `-cpu max` expands to different feature sets under each accelerator. + +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 + EMULATOR_IMAGE_DIR: ${{ github.workspace }}/docker/local-emulator/qemu/images + EMULATOR_RUN_DIR: ${{ github.workspace }}/docker/local-emulator/qemu/run + +jobs: + build: + name: Build QEMU Image (arm64) + runs-on: macos-15 + timeout-minutes: 120 + + 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: Set up Docker via colima + run: | + brew install docker docker-buildx colima + mkdir -p ~/.docker/cli-plugins + ln -sfn "$(brew --prefix docker-buildx)/bin/docker-buildx" ~/.docker/cli-plugins/docker-buildx + colima start --cpu 4 --memory 6 --disk 60 --arch aarch64 + docker info + docker buildx version + + - 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: Build QEMU image + run: | + chmod +x docker/local-emulator/qemu/build-image.sh + EMULATOR_PROVISION_TIMEOUT=6000 \ + docker/local-emulator/qemu/build-image.sh arm64 + + - name: Generate emulator env + run: node docker/local-emulator/generate-env-development.mjs + + # HVF gives us native-speed arm64 — we can verify the image boots + # and services come up, unlike the old cross-arch TCG path. + - 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 + EMULATOR_IMAGE_DIR: ${{ env.EMULATOR_IMAGE_DIR }} + EMULATOR_RUN_DIR: ${{ env.EMULATOR_RUN_DIR }} + run: node packages/stack-cli/dist/index.js emulator start + + - name: Verify services are healthy + env: + EMULATOR_ARCH: arm64 + EMULATOR_IMAGE_DIR: ${{ env.EMULATOR_IMAGE_DIR }} + EMULATOR_RUN_DIR: ${{ env.EMULATOR_RUN_DIR }} + run: node packages/stack-cli/dist/index.js emulator status + + - name: Stop emulator + if: always() + env: + EMULATOR_ARCH: arm64 + EMULATOR_IMAGE_DIR: ${{ env.EMULATOR_IMAGE_DIR }} + EMULATOR_RUN_DIR: ${{ env.EMULATOR_RUN_DIR }} + 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="docker/local-emulator/qemu/images/stack-emulator-arm64.qcow2" + SAVEVM="docker/local-emulator/qemu/images/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 diff --git a/.github/workflows/qemu-emulator-build.yaml b/.github/workflows/qemu-emulator-build.yaml index e2298401d..81cb509e4 100644 --- a/.github/workflows/qemu-emulator-build.yaml +++ b/.github/workflows/qemu-emulator-build.yaml @@ -35,16 +35,11 @@ jobs: matrix: include: # amd64 runs natively under KVM on ubicloud's amd64 runner. + # arm64 is built in a separate workflow on a macOS runner (HVF) + # so that the snapshot is portable to developer Macs. + # See qemu-emulator-build-arm64.yaml. - arch: amd64 runner: ubicloud-standard-8 - # arm64 runs under cross-arch TCG on ubicloud's amd64 runner. - # No KVM for arm64 guests on an amd64 host; cortex-a72 + V8 - # --jitless together sidestep the SIGTRAPs that cross-arch TCG - # hits on aggressive arm64 JIT code. Smoke test is still skipped - # because the backend can't come up reliably under cross-arch - # TCG within any sane window. - - arch: arm64 - runner: ubicloud-standard-8 steps: - uses: actions/checkout@v6 diff --git a/packages/stack-cli/src/commands/emulator.ts b/packages/stack-cli/src/commands/emulator.ts index 12b308089..9088bbc3f 100644 --- a/packages/stack-cli/src/commands/emulator.ts +++ b/packages/stack-cli/src/commands/emulator.ts @@ -1,7 +1,7 @@ import { Command } from "commander"; import { execFileSync, spawn } from "child_process"; import extract from "extract-zip"; -import { createWriteStream, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync } from "fs"; +import { chmodSync, createWriteStream, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync } from "fs"; import { homedir } from "os"; import { dirname, join, resolve } from "path"; import { Readable } from "stream"; @@ -143,12 +143,18 @@ async function ghApi(path: string): Promise { function emulatorScriptsDir(): string { const here = dirname(fileURLToPath(import.meta.url)); const bundled = join(here, "emulator"); - if (existsSync(join(bundled, "run-emulator.sh"))) return bundled; + if (existsSync(join(bundled, "run-emulator.sh"))) return ensureExecutable(bundled); const repo = resolve(here, "../../../docker/local-emulator/qemu"); - if (existsSync(join(repo, "run-emulator.sh"))) return repo; + if (existsSync(join(repo, "run-emulator.sh"))) return ensureExecutable(repo); throw new CliError("Emulator scripts not found in CLI bundle."); } +// npm pack strips the execute bit from non-`bin` files, so restore it here. +function ensureExecutable(scriptsDir: string): string { + try { chmodSync(join(scriptsDir, "run-emulator.sh"), 0o755); } catch { /* best-effort */ } + return scriptsDir; +} + function baseEnvPath(): string { // Lives one directory up from the scripts dir in both bundled and repo // layouts (dist/.env.development vs docker/local-emulator/.env.development). @@ -467,7 +473,7 @@ async function downloadArtifactByName(repo: string, runId: string, name: string, console.log(`Downloading artifact '${name}' from run ${runId}...`); await downloadWithProgress( `${GITHUB_API}/repos/${repo}/actions/artifacts/${match.id}/zip`, - { Accept: "application/octet-stream", Authorization: `Bearer ${token}` }, + { Accept: "application/vnd.github+json", Authorization: `Bearer ${token}` }, zipPath, match.size_in_bytes, );