name: Check

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}

permissions:
  contents: read

env:
  CARGO_TERM_COLOR: always
  CARGO_NET_RETRY: "10"
  CARGO_HTTP_TIMEOUT: "120"
  CARGO_HTTP_MULTIPLEXING: "false"
  LIBKRUNFW_VERSION: "5.6.1"
  LIBKRUNFW_ABI: "5"

jobs:
  # ---------------------------------------------------------------------------
  # Detect whether the diff touches anything outside docs/. When it doesn't,
  # every downstream job is gated off and posts as "skipped", which satisfies
  # the required status checks on main without burning CI minutes.
  # ---------------------------------------------------------------------------
  changes:
    name: Detect code changes
    runs-on: ubuntu-latest
    outputs:
      code: ${{ steps.filter.outputs.code }}
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
        id: filter
        with:
          filters: |
            code:
              - '!docs/**'

  # ---------------------------------------------------------------------------
  # Build kernel.c on Linux for macOS libkrunfw linking
  # ---------------------------------------------------------------------------
  build-kernel:
    name: Build kernel.c (aarch64)
    needs: changes
    if: needs.changes.outputs.code == 'true'
    runs-on: ubuntu-24.04-arm
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

      - name: Check out libkrunfw
        run: git submodule update --init --depth 1 vendor/libkrunfw

      - name: Cache kernel.c
        id: cache-kernel
        uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
        with:
          path: vendor/libkrunfw/kernel.c
          # The build recipe is inline below, so hash this workflow along with
          # the source to prevent reusing output built with stale flags.
          key: kernel-c-aarch64-${{ hashFiles('vendor/libkrunfw/**', '.github/actions/cache-libkrunfw-kernel/action.yml', '.github/workflows/check.yml') }}

      - name: Prepare kernel source
        if: steps.cache-kernel.outputs.cache-hit != 'true'
        uses: ./.github/actions/cache-libkrunfw-kernel

      - name: Install kernel build deps
        if: steps.cache-kernel.outputs.cache-hit != 'true'
        run: sudo apt-get update && sudo apt-get install -y libcap-ng-dev gcc make flex bison libelf-dev bc python3-pyelftools

      - name: Build kernel.c
        if: steps.cache-kernel.outputs.cache-hit != 'true'
        run: |
          cd vendor/libkrunfw
          make -j$(nproc)

      - name: Upload kernel.c
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: kernel-c-aarch64
          path: vendor/libkrunfw/kernel.c

  build-kernel-x86_64:
    name: Build kernel.c (x86_64)
    needs: changes
    if: needs.changes.outputs.code == 'true'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

      - name: Check out libkrunfw
        run: git submodule update --init --depth 1 vendor/libkrunfw

      - name: Cache kernel.c
        id: cache-kernel
        uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
        with:
          path: vendor/libkrunfw/kernel.c
          # The build recipe is inline below, so hash this workflow along with
          # the source to prevent reusing output built with stale flags.
          key: kernel-c-x86_64-${{ hashFiles('vendor/libkrunfw/**', '.github/actions/cache-libkrunfw-kernel/action.yml', '.github/workflows/check.yml') }}

      - name: Prepare kernel source
        if: steps.cache-kernel.outputs.cache-hit != 'true'
        uses: ./.github/actions/cache-libkrunfw-kernel

      - name: Install kernel build deps
        if: steps.cache-kernel.outputs.cache-hit != 'true'
        run: sudo apt-get update && sudo apt-get install -y libcap-ng-dev gcc make flex bison libelf-dev bc python3-pyelftools

      - name: Build kernel.c
        if: steps.cache-kernel.outputs.cache-hit != 'true'
        run: |
          cd vendor/libkrunfw
          make -j$(nproc)

      - name: Upload kernel.c
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: kernel-c-x86_64
          path: vendor/libkrunfw/kernel.c

  # ---------------------------------------------------------------------------
  # Build agentd on Linux for macOS packaging
  # ---------------------------------------------------------------------------
  build-agentd-aarch64:
    name: Build agentd (aarch64-linux-musl)
    needs: changes
    if: needs.changes.outputs.code == 'true'
    runs-on: ubuntu-24.04-arm
    env:
      CARGO_INCREMENTAL: "0"
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

      - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable

      - uses: Swatinem/rust-cache@258712b0b7b1ddf8bddc9fc3b0faca682b2736c3 # v2
        with:
          cache-bin: false
          cache-targets: true

      - name: Install agentd build deps
        run: sudo apt-get update && sudo apt-get install -y musl-tools

      - name: Build agentd
        run: |
          rustup target add --toolchain stable aarch64-unknown-linux-musl
          cargo +stable build --profile ci --manifest-path crates/agentd/Cargo.toml --target aarch64-unknown-linux-musl
          mkdir -p build
          cp target/aarch64-unknown-linux-musl/ci/agentd build/agentd

      - name: Upload agentd
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: agentd-aarch64-linux-musl
          path: build/agentd

  build-agentd-x86_64:
    name: Build agentd (x86_64-linux-musl)
    needs: changes
    if: needs.changes.outputs.code == 'true'
    runs-on: ${{ vars.CI_LINUX_X86_RUNNER || 'ubuntu-latest' }}
    env:
      CARGO_INCREMENTAL: "0"
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

      - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable

      - uses: Swatinem/rust-cache@258712b0b7b1ddf8bddc9fc3b0faca682b2736c3 # v2
        with:
          cache-bin: false
          cache-targets: true

      - name: Install agentd build deps
        run: sudo apt-get update && sudo apt-get install -y musl-tools

      - name: Build agentd
        run: |
          rustup target add --toolchain stable x86_64-unknown-linux-musl
          cargo +stable build --profile ci --manifest-path crates/agentd/Cargo.toml --target x86_64-unknown-linux-musl
          mkdir -p build
          cp target/x86_64-unknown-linux-musl/ci/agentd build/agentd

      - name: Upload agentd
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: agentd-x86_64-linux-musl
          path: build/agentd

  # ---------------------------------------------------------------------------
  # Check
  # ---------------------------------------------------------------------------
  build-linux-x86_64:
    name: Build runtime (linux-x86_64)
    needs: [build-agentd-x86_64, changes]
    if: needs.changes.outputs.code == 'true'
    # CI_LINUX_X86_RUNNER can point at an organization larger-runner label.
    # Default to a standard hosted runner so unconfigured repositories do not queue indefinitely.
    runs-on: ${{ vars.CI_LINUX_X86_RUNNER || 'ubuntu-latest' }}
    timeout-minutes: 45
    env:
      CARGO_INCREMENTAL: "0"
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

      # This build only links libkrunfw. Fetching every example image, MCP,
      # and skills submodule has made checkout the dominant x86 critical path.
      - name: Check out libkrunfw
        run: git submodule update --init --depth 1 vendor/libkrunfw

      - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable

      # Cache dependency artifacts as one archive instead of uploading every
      # compiler result separately through the GitHub Actions cache API.
      - uses: Swatinem/rust-cache@258712b0b7b1ddf8bddc9fc3b0faca682b2736c3 # v2
        with:
          cache-bin: false
          cache-targets: true

      - name: Install build deps
        run: sudo apt-get update && sudo apt-get install -y libcap-ng-dev gcc make flex bison libelf-dev bc python3-pyelftools

      - name: Download agentd
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          name: agentd-x86_64-linux-musl
          path: build/

      - name: Cache libkrunfw
        id: cache-libkrunfw
        uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
        with:
          path: build/libkrunfw*
          # Include the inline build recipe and ABI/version environment in the
          # key as well as the libkrunfw source.
          key: libkrunfw-linux-x86_64-${{ hashFiles('vendor/libkrunfw/**', '.github/actions/cache-libkrunfw-kernel/action.yml', '.github/workflows/check.yml') }}

      - name: Prepare kernel source
        if: steps.cache-libkrunfw.outputs.cache-hit != 'true'
        uses: ./.github/actions/cache-libkrunfw-kernel

      - name: Build libkrunfw
        if: steps.cache-libkrunfw.outputs.cache-hit != 'true'
        run: |
          cd vendor/libkrunfw
          make -j"$(nproc)"
          cd ../..
          mkdir -p build
          cp vendor/libkrunfw/libkrunfw.so.${{ env.LIBKRUNFW_VERSION }} build/
          cd build
          ln -sf libkrunfw.so.${{ env.LIBKRUNFW_VERSION }} libkrunfw.so.${{ env.LIBKRUNFW_ABI }}
          ln -sf libkrunfw.so.${{ env.LIBKRUNFW_ABI }} libkrunfw.so

      # mold keeps uncached workspace links fast.
      - name: Set up mold
        uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # 2.41.0

      - name: Build msb
        run: |
          cargo build --profile ci --no-default-features --features net,ssh -p microsandbox-cli
          cp target/ci/msb build/msb

      # Upload immediately. All KVM lanes can begin while independent quality
      # and cross-platform checks continue in parallel.
      - name: Upload runtime artifacts
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: msb-linux-x86_64
          path: build/

  go-ffi-build:
    name: Build Go FFI (linux-x86_64)
    needs: [build-linux-x86_64, changes]
    if: needs.changes.outputs.code == 'true'
    runs-on: ${{ vars.CI_LINUX_X86_RUNNER || 'ubuntu-latest' }}
    timeout-minutes: 30
    env:
      CARGO_INCREMENTAL: "0"
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

      - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable

      - uses: Swatinem/rust-cache@258712b0b7b1ddf8bddc9fc3b0faca682b2736c3 # v2
        with:
          cache-bin: false
          cache-targets: true

      - name: Install build deps
        run: sudo apt-get update && sudo apt-get install -y libcap-ng-dev

      - name: Download runtime artifacts
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          name: msb-linux-x86_64
          path: build/

      - name: Set up mold
        uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # 2.41.0

      - name: Build Go FFI
        run: cargo build --profile ci -p microsandbox-go

      - name: Upload Go FFI artifact
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: go-ffi-linux-x86_64
          path: target/ci/libmicrosandbox_go_ffi.so

  rust-integration-build:
    name: Build Rust integration tests
    needs: [build-agentd-x86_64, changes]
    if: needs.changes.outputs.code == 'true'
    runs-on: ${{ vars.CI_LINUX_X86_RUNNER || 'ubuntu-latest' }}
    timeout-minutes: 45
    env:
      CARGO_INCREMENTAL: "0"
      # Keep the archive small while preserving file:line backtraces.
      CARGO_PROFILE_DEV_DEBUG: "line-tables-only"
      CARGO_PROFILE_TEST_DEBUG: "line-tables-only"
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

      - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable

      - uses: Swatinem/rust-cache@258712b0b7b1ddf8bddc9fc3b0faca682b2736c3 # v2
        with:
          cache-bin: false
          cache-targets: true

      - uses: taiki-e/install-action@7f4eb899022d8fe70b20c4f3de697aa85c309026 # v2
        with:
          tool: cargo-nextest@0.9.143

      - name: Set up mold
        uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # 2.41.0

      - name: Install build dependencies
        run: sudo apt-get update && sudo apt-get install -y libcap-ng-dev

      - name: Download agentd
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          name: agentd-x86_64-linux-musl
          path: build/

      - name: Build nextest archive
        run: |
          chmod +x build/agentd
          mkdir -p artifacts
          # The target KVM job installs the freshly built runtime separately.
          # Omitting `prebuilt` here avoids waiting for that artifact solely to
          # satisfy SDK build scripts; the downloaded current agentd is embedded.
          mold -run cargo nextest archive -p microsandbox --tests \
            --no-default-features --features keyring,net \
            --archive-file artifacts/rust-integration-tests.tar.zst

      - name: Upload nextest archive
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: rust-integration-tests-linux-x86_64
          path: artifacts/rust-integration-tests.tar.zst
          compression-level: 0

  check:
    name: Check (${{ matrix.target }})
    needs: [build-kernel, build-agentd-aarch64, changes]
    if: always() && needs.changes.outputs.code == 'true'
    strategy:
      fail-fast: false
      matrix:
        include:
          - target: linux-aarch64
            runner: ubuntu-24.04-arm
            os: linux
            agentd_target: aarch64-unknown-linux-musl
            libkrunfw_file: libkrunfw.so.5.6.1
          - target: darwin-aarch64
            runner: macos-14
            os: darwin
            agentd_target: ""
            libkrunfw_file: libkrunfw.5.dylib
    uses: ./.github/workflows/check-platform.yml
    with:
      target: ${{ matrix.target }}
      runner: ${{ matrix.runner }}
      os: ${{ matrix.os }}
      agentd_target: ${{ matrix.agentd_target }}
      libkrunfw_file: ${{ matrix.libkrunfw_file }}

  # ---------------------------------------------------------------------------
  # Unit tests reuse the runtime artifacts produced above. Keeping them in the
  # same workflow removes the second release/LTO build formerly done by Test.
  # ---------------------------------------------------------------------------
  test-linux-x86_64:
    name: Test (linux-x86_64)
    needs: [build-linux-x86_64, changes]
    if: needs.changes.outputs.code == 'true'
    uses: ./.github/workflows/test-platform.yml
    with:
      target: linux-x86_64
      runner: ${{ vars.CI_LINUX_X86_RUNNER || 'ubuntu-latest' }}
      os: linux
      agentd_target: x86_64-unknown-linux-musl

  # ---------------------------------------------------------------------------
  # Windows build/check only.
  #
  # These jobs verify the WHP host targets compile on both Windows architectures.
  # VM smoke and integration coverage intentionally stays Linux/KVM-only below.
  # Quality and linked release builds use separate runners so dev/test compilation
  # does not serialize the expensive release and Go FFI links.
  # ---------------------------------------------------------------------------
  windows-quality:
    name: Windows Quality (${{ matrix.target }})
    needs: [build-kernel, build-kernel-x86_64, build-agentd-aarch64, build-agentd-x86_64, changes]
    if: needs.changes.outputs.code == 'true'
    runs-on: ${{ matrix.runner }}
    timeout-minutes: 45
    env:
      CARGO_INCREMENTAL: "0"
    strategy:
      fail-fast: false
      matrix:
        include:
          - target: windows-aarch64
            runner: windows-11-arm
            rust_target: aarch64-pc-windows-msvc
            kernel_artifact: kernel-c-aarch64
            agentd_artifact: agentd-aarch64-linux-musl
            vs_arch: arm64
            vs_host_arch: arm64
          - target: windows-x86_64
            runner: windows-latest
            rust_target: x86_64-pc-windows-msvc
            kernel_artifact: kernel-c-x86_64
            agentd_artifact: agentd-x86_64-linux-musl
            vs_arch: amd64
            vs_host_arch: amd64

    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

      - name: Check out libkrunfw
        run: git submodule update --init --depth 1 vendor/libkrunfw

      - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
        with:
          components: clippy
          targets: ${{ matrix.rust_target }}

      - uses: Swatinem/rust-cache@258712b0b7b1ddf8bddc9fc3b0faca682b2736c3 # v2
        with:
          cache-bin: false
          # Preserve Cargo fingerprints and linked outputs across quality runs.
          cache-targets: true
          shared-key: windows-quality-${{ matrix.target }}

      - name: Download kernel.c
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          name: ${{ matrix.kernel_artifact }}
          path: vendor/libkrunfw/

      - name: Download agentd
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          name: ${{ matrix.agentd_artifact }}
          path: build/

      - name: Build libkrunfw.dll
        shell: pwsh
        run: |
          $ErrorActionPreference = "Stop"
          & .\vendor\libkrunfw\scripts\build-windows.ps1 `
            -SkipKernelBundle `
            -AbiVersion ${{ env.LIBKRUNFW_ABI }} `
            -Architecture ${{ matrix.vs_arch }} `
            -HostArchitecture ${{ matrix.vs_host_arch }} `
            -Output libkrunfw.dll `
            -ImportLibrary libkrunfw.lib
          New-Item -ItemType Directory -Force -Path build | Out-Null
          Copy-Item vendor\libkrunfw\libkrunfw.dll build\libkrunfw.dll -Force

      - name: Check msb
        shell: pwsh
        run: |
          $ErrorActionPreference = "Stop"
          . "$env:GITHUB_WORKSPACE\vendor\libkrunfw\scripts\msvc-env.ps1"
          Set-MsvcEnvironment -Architecture ${{ matrix.vs_arch }} -HostArchitecture ${{ matrix.vs_host_arch }}
          cargo +stable check --no-default-features --features net,ssh -p microsandbox-cli --target ${{ matrix.rust_target }}

      - name: Clippy msb
        shell: pwsh
        run: |
          $ErrorActionPreference = "Stop"
          . "$env:GITHUB_WORKSPACE\vendor\libkrunfw\scripts\msvc-env.ps1"
          Set-MsvcEnvironment -Architecture ${{ matrix.vs_arch }} -HostArchitecture ${{ matrix.vs_host_arch }}
          cargo +stable clippy --no-default-features --features net,ssh -p microsandbox-cli --target ${{ matrix.rust_target }} -- -D warnings

      - name: Test bind rootfs backend
        shell: pwsh
        run: |
          $ErrorActionPreference = "Stop"
          . "$env:GITHUB_WORKSPACE\vendor\libkrunfw\scripts\msvc-env.ps1"
          Set-MsvcEnvironment -Architecture ${{ matrix.vs_arch }} -HostArchitecture ${{ matrix.vs_host_arch }}
          cargo +stable test --no-default-features --features net -p microsandbox-runtime --lib --target ${{ matrix.rust_target }} test_bind_rootfs_backend_exposes_host_file_and_init

  windows-build:
    name: Build Windows ${{ matrix.component }} (${{ matrix.target }})
    needs: [build-kernel, build-kernel-x86_64, build-agentd-aarch64, build-agentd-x86_64, changes]
    if: needs.changes.outputs.code == 'true'
    runs-on: ${{ matrix.runner }}
    timeout-minutes: 45
    env:
      CARGO_INCREMENTAL: "0"
    strategy:
      fail-fast: false
      matrix:
        include:
          - target: windows-aarch64
            component: CLI
            runner: windows-11-arm
            rust_target: aarch64-pc-windows-msvc
            kernel_artifact: kernel-c-aarch64
            agentd_artifact: agentd-aarch64-linux-musl
            vs_arch: arm64
            vs_host_arch: arm64
          - target: windows-x86_64
            component: CLI
            runner: windows-latest
            rust_target: x86_64-pc-windows-msvc
            kernel_artifact: kernel-c-x86_64
            agentd_artifact: agentd-x86_64-linux-musl
            vs_arch: amd64
            vs_host_arch: amd64
          - target: windows-aarch64
            component: Go
            runner: windows-11-arm
            rust_target: aarch64-pc-windows-msvc
            go_arch: arm64
            go_cc: clang
            kernel_artifact: kernel-c-aarch64
            agentd_artifact: agentd-aarch64-linux-musl
            vs_arch: arm64
            vs_host_arch: arm64
          - target: windows-x86_64
            component: Go
            runner: windows-latest
            rust_target: x86_64-pc-windows-msvc
            go_arch: amd64
            go_cc: clang -fuse-ld=lld
            kernel_artifact: kernel-c-x86_64
            agentd_artifact: agentd-x86_64-linux-musl
            vs_arch: amd64
            vs_host_arch: amd64

    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

      - name: Check out libkrunfw
        run: git submodule update --init --depth 1 vendor/libkrunfw

      - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
        with:
          targets: ${{ matrix.rust_target }}

      - uses: Swatinem/rust-cache@258712b0b7b1ddf8bddc9fc3b0faca682b2736c3 # v2
        with:
          cache-bin: false
          # Retain exact Cargo outputs, including release links.
          cache-targets: true
          shared-key: windows-build-${{ matrix.target }}-${{ matrix.component }}

      - name: Download kernel.c
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          name: ${{ matrix.kernel_artifact }}
          path: vendor/libkrunfw/

      - name: Download agentd
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          name: ${{ matrix.agentd_artifact }}
          path: build/

      - name: Build libkrunfw.dll
        shell: pwsh
        run: |
          $ErrorActionPreference = "Stop"
          & .\vendor\libkrunfw\scripts\build-windows.ps1 `
            -SkipKernelBundle `
            -AbiVersion ${{ env.LIBKRUNFW_ABI }} `
            -Architecture ${{ matrix.vs_arch }} `
            -HostArchitecture ${{ matrix.vs_host_arch }} `
            -Output libkrunfw.dll `
            -ImportLibrary libkrunfw.lib
          New-Item -ItemType Directory -Force -Path build | Out-Null
          Copy-Item vendor\libkrunfw\libkrunfw.dll build\libkrunfw.dll -Force

      - name: Build msb
        if: matrix.component == 'CLI'
        shell: pwsh
        env:
          RUSTFLAGS: -C target-feature=+crt-static
        run: |
          $ErrorActionPreference = "Stop"
          . "$env:GITHUB_WORKSPACE\vendor\libkrunfw\scripts\msvc-env.ps1"
          Set-MsvcEnvironment -Architecture ${{ matrix.vs_arch }} -HostArchitecture ${{ matrix.vs_host_arch }}
          cargo +stable build --profile ci --no-default-features --features net,ssh -p microsandbox-cli --target ${{ matrix.rust_target }}

      - name: Build Go FFI cdylib
        if: matrix.component == 'Go'
        shell: pwsh
        env:
          RUSTFLAGS: -C target-feature=+crt-static
        run: |
          $ErrorActionPreference = "Stop"
          . "$env:GITHUB_WORKSPACE\vendor\libkrunfw\scripts\msvc-env.ps1"
          Set-MsvcEnvironment -Architecture ${{ matrix.vs_arch }} -HostArchitecture ${{ matrix.vs_host_arch }}
          cargo +stable build --profile ci -p microsandbox-go --target ${{ matrix.rust_target }}

      - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
        if: matrix.component == 'Go'
        with:
          go-version: stable
          cache: false

      - name: Build and test Go SDK
        if: matrix.component == 'Go'
        shell: pwsh
        working-directory: sdk/go
        env:
          CC: ${{ matrix.go_cc }}
          CGO_ENABLED: "1"
          GOARCH: ${{ matrix.go_arch }}
          GOOS: windows
        run: |
          $ErrorActionPreference = "Stop"
          . "$env:GITHUB_WORKSPACE\vendor\libkrunfw\scripts\msvc-env.ps1"
          Set-MsvcEnvironment -Architecture ${{ matrix.vs_arch }} -HostArchitecture ${{ matrix.vs_host_arch }}
          go build ./...
          go test -count=1 .

  # ---------------------------------------------------------------------------
  # Platform-independent quality lanes. These run once on Linux instead of
  # serially repeating on Linux arm64 and macOS.
  # ---------------------------------------------------------------------------
  rust-quality:
    name: Rust Quality
    needs: [build-linux-x86_64, changes]
    if: needs.changes.outputs.code == 'true'
    runs-on: ${{ vars.CI_LINUX_X86_RUNNER || 'ubuntu-latest' }}
    timeout-minutes: 45
    env:
      CARGO_INCREMENTAL: "0"
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

      - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
        with:
          components: clippy, rustfmt
          targets: x86_64-unknown-linux-musl

      - uses: Swatinem/rust-cache@258712b0b7b1ddf8bddc9fc3b0faca682b2736c3 # v2
        with:
          cache-bin: false
          cache-targets: true

      - name: Install build deps
        run: sudo apt-get update && sudo apt-get install -y musl-tools libcap-ng-dev

      - name: Download runtime artifacts
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          name: msb-linux-x86_64
          path: build/

      - name: Format
        run: cargo +stable fmt --all -- --check

      - name: Check agentd
        run: |
          cargo +stable fmt --manifest-path crates/agentd/Cargo.toml -- --check
          cargo +stable clippy --manifest-path crates/agentd/Cargo.toml --target x86_64-unknown-linux-musl -- -D warnings

      - name: Set up mold
        uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # 2.41.0

      - name: Clippy
        run: cargo +stable clippy --workspace --exclude microsandbox-agentd -- -D warnings

      - name: Docs
        env:
          RUSTDOCFLAGS: "-D warnings"
        run: cargo +stable doc --workspace --exclude microsandbox-agentd --no-deps

      - name: Check generated microsandbox types
        run: cargo +stable run -p microsandbox-types --features ts --bin microsandbox-types-generate -- --check

  node-sdk-build:
    name: TypeScript and Node Quality
    needs: [build-linux-x86_64, changes]
    if: needs.changes.outputs.code == 'true'
    runs-on: ${{ vars.CI_LINUX_X86_RUNNER || 'ubuntu-latest' }}
    timeout-minutes: 45
    env:
      CARGO_INCREMENTAL: "0"
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

      - name: Check out MCP server
        run: git submodule update --init --depth 1 mcp

      - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable

      - uses: Swatinem/rust-cache@258712b0b7b1ddf8bddc9fc3b0faca682b2736c3 # v2
        with:
          cache-bin: false
          cache-targets: true

      - name: Set up mold
        uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # 2.41.0

      - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
        with:
          node-version: 22

      - name: Install system dependencies
        run: sudo apt-get update && sudo apt-get install -y libcap-ng-dev

      - name: Download runtime artifacts
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          name: msb-linux-x86_64
          path: build/

      - name: Check agent client package
        working-directory: packages/agent-client/typescript
        run: npm ci && npm run build && npm run typecheck && npm test

      - name: Check microsandbox types package
        working-directory: packages/microsandbox-types/typescript
        run: npm ci && npm run build && npm run typecheck

      - name: Build Node SDK
        working-directory: sdk/node-ts
        run: |
          node scripts/prune-platform-optional-deps.mjs
          npm install --package-lock=false
          npm run build:ci

      - name: Build MCP server
        working-directory: mcp
        run: |
          node -e "
            const fs = require('fs');
            const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
            pkg.dependencies.microsandbox = 'file:../sdk/node-ts';
            fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2));
          "
          npm install --no-package-lock --ignore-scripts
          npm run build

      - name: Upload Node SDK artifacts
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: node-sdk-linux-x86_64
          path: |
            sdk/node-ts/native/index.cjs
            sdk/node-ts/native/index.d.ts
            sdk/node-ts/native/microsandbox.*.node
            sdk/node-ts/dist/
            sdk/node-ts/package.json
            sdk/node-ts/package-lock.json
            sdk/node-ts/scripts/prune-platform-optional-deps.mjs
            sdk/node-ts/tests/
            sdk/node-ts/tsconfig.json
            sdk/node-ts/vitest.config.ts

  python-wheel-build:
    name: Build Python wheel (linux-x86_64)
    needs: [build-linux-x86_64, changes]
    if: needs.changes.outputs.code == 'true'
    runs-on: ${{ vars.CI_LINUX_X86_RUNNER || 'ubuntu-latest' }}
    timeout-minutes: 45
    env:
      CARGO_INCREMENTAL: "0"
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

      - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable

      - uses: Swatinem/rust-cache@258712b0b7b1ddf8bddc9fc3b0faca682b2736c3 # v2
        with:
          cache-bin: false
          cache-targets: true

      - name: Set up mold
        uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # 2.41.0

      - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
        with:
          enable-cache: true
          cache-dependency-glob: "sdk/python/uv.lock"

      - name: Install system dependencies
        run: sudo apt-get update && sudo apt-get install -y libcap-ng-dev

      - name: Download runtime artifacts
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          name: msb-linux-x86_64
          path: build/

      - name: Stage runtime bundle
        run: |
          mkdir -p sdk/python/microsandbox/_bundled/bin sdk/python/microsandbox/_bundled/lib
          cp build/msb sdk/python/microsandbox/_bundled/bin/
          cp build/libkrunfw.so.${{ env.LIBKRUNFW_VERSION }} sdk/python/microsandbox/_bundled/lib/
          cd sdk/python/microsandbox/_bundled/lib
          ln -sf libkrunfw.so.${{ env.LIBKRUNFW_VERSION }} libkrunfw.so.${{ env.LIBKRUNFW_ABI }}
          ln -sf libkrunfw.so.${{ env.LIBKRUNFW_ABI }} libkrunfw.so

      - name: Build Python wheel
        working-directory: sdk/python
        run: |
          # Avoid uv's implicit editable build; every x86 Python lane consumes
          # the exact same cp310-abi3 wheel produced here.
          uv sync --group dev --no-install-project
          mold -run uv run --no-sync maturin build --profile ci --out dist

      - name: Upload Python wheel
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: python-wheel-linux-x86_64
          path: sdk/python/dist/*.whl
          compression-level: 0

  python-quality:
    name: Python Quality
    needs: [python-wheel-build, changes]
    if: needs.changes.outputs.code == 'true'
    runs-on: ${{ vars.CI_LINUX_X86_RUNNER || 'ubuntu-latest' }}
    timeout-minutes: 20
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

      - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
        with:
          enable-cache: true
          cache-dependency-glob: "sdk/python/uv.lock"

      - name: Download Python wheel
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          name: python-wheel-linux-x86_64
          path: sdk/python/dist/

      - name: Install Python development environment
        working-directory: sdk/python
        run: |
          uv sync --group dev --no-install-project
          uv pip install --reinstall dist/*.whl

      - name: Test and lint Python SDK
        run: |
          # Running from the repository root ensures imports come from the
          # installed wheel instead of the adjacent Python source tree.
          uv run --project sdk/python --no-sync pytest sdk/python/tests
          uv run --project sdk/python --no-sync ruff check sdk/python

  go-quality:
    name: Go Quality
    needs: [build-linux-x86_64, go-ffi-build, changes]
    if: needs.changes.outputs.code == 'true'
    runs-on: ubuntu-latest
    timeout-minutes: 20
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

      - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
        with:
          go-version: stable
          cache: false

      - name: Download runtime artifacts
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          name: msb-linux-x86_64
          path: build/

      - name: Download Go FFI artifact
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          name: go-ffi-linux-x86_64
          path: build/

      - name: Build, vet, and test Go SDK
        working-directory: sdk/go
        env:
          MICROSANDBOX_FFI_PATH: ${{ github.workspace }}/build/libmicrosandbox_go_ffi.so
        run: |
          go build ./...
          go vet ./...
          go test -count=1 .
          go test -tags "smoke microsandbox_ffi_path" -count=1 -timeout 2m .

  # ---------------------------------------------------------------------------
  # CLI smoke tests (requires KVM)
  #
  # Runs black-box CLI user flows against the freshly-built msb artifact.
  # It runs alongside the integration lanes so failures retain a direct signal
  # without delaying SDK coverage.
  # ---------------------------------------------------------------------------
  cli-smoke-test:
    name: CLI Smoke Tests
    needs: [build-linux-x86_64, changes]
    if: needs.changes.outputs.code == 'true'
    runs-on: self-hosted-ubuntu-2404-x64
    # Bound a wedged run so it can't squat a shared runner for the 6h default.
    timeout-minutes: 45
    steps:
      - name: Clean workspace
        run: |
          rm -rf "${{ github.workspace }}"/build
          rm -rf ~/.microsandbox

      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

      - name: Clean runner disk
        run: scripts/ci/clean-runner-disk.sh

      - name: Download build artifacts
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          name: msb-linux-x86_64
          path: build/

      - name: Run CLI smoke tests
        run: |
          chmod +x build/msb
          ln -sf libkrunfw.so.${{ env.LIBKRUNFW_VERSION }} build/libkrunfw.so.${{ env.LIBKRUNFW_ABI }}
          ln -sf libkrunfw.so.${{ env.LIBKRUNFW_ABI }} build/libkrunfw.so
          scripts/smoke/cli/image-archive.sh
          scripts/smoke/cli/split-irqchip-bind-net.sh

      - name: Disk usage
        if: always()
        run: scripts/ci/clean-runner-disk.sh

  # ---------------------------------------------------------------------------
  # Integration tests (requires KVM)
  # ---------------------------------------------------------------------------
  integration-test:
    name: Integration Tests
    needs: [build-linux-x86_64, rust-integration-build, changes]
    if: always() && needs.changes.outputs.code == 'true' && needs.build-linux-x86_64.result == 'success' && needs.rust-integration-build.result == 'success'
    runs-on: self-hosted-ubuntu-2404-x64
    # Bound a wedged run so it can't squat a shared runner for the 6h default.
    timeout-minutes: 45
    steps:
      - name: Clean workspace
        run: |
          rm -rf "${{ github.workspace }}"/{build}
          rm -rf ~/.microsandbox

      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          submodules: true

      - name: Clean runner disk
        run: scripts/ci/clean-runner-disk.sh

      - uses: taiki-e/install-action@7f4eb899022d8fe70b20c4f3de697aa85c309026 # v2
        with:
          tool: cargo-nextest@0.9.143

      # -- Download pre-built artifacts --
      - name: Download build artifacts
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          name: msb-linux-x86_64
          path: build/

      - name: Download nextest archive
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          name: rust-integration-tests-linux-x86_64
          path: build/

      # -- Install msb + libkrunfw --
      - name: Install msb
        run: |
          chmod +x build/msb
          mkdir -p ~/.microsandbox/bin ~/.microsandbox/lib
          install -m755 build/msb ~/.microsandbox/bin/msb
          install -m644 build/libkrunfw.so.${{ env.LIBKRUNFW_VERSION }} ~/.microsandbox/lib/
          ln -sf libkrunfw.so.${{ env.LIBKRUNFW_VERSION }} ~/.microsandbox/lib/libkrunfw.so.${{ env.LIBKRUNFW_ABI }}
          ln -sf libkrunfw.so.${{ env.LIBKRUNFW_ABI }} ~/.microsandbox/lib/libkrunfw.so

      - name: Prepare pre-built agentd
        # upload-artifact normalizes executable bits. The runtime build already
        # produced agentd; restore its mode instead of compiling it again here.
        run: chmod +x build/agentd

      # -- Run integration tests --
      # MSB_TEST_ISOLATE_HOME=1 turns on per-test ~/.microsandbox isolation
      # (see crates/test-utils), so tests avoid sqlite/image-cache contention.
      # Per-test home isolation makes bounded nextest parallelism safe. Keep
      # this at two: stdin-heavy tests and overlapping microVM boots have lost
      # relay/session readiness when the shared KVM host is oversubscribed.
      - name: Run integration tests
        env:
          MSB_TEST_ISOLATE_HOME: "1"
        run: |
          export PATH="$HOME/.microsandbox/bin:$PATH"
          export LD_LIBRARY_PATH="${{ github.workspace }}/build:$HOME/.microsandbox/lib"
          # Functional VM tests deliberately create multiple writable-root sandboxes. Give this
          # dedicated runner a stable admission pool so unrelated tests do not depend on its
          # momentary MemAvailable-derived pool; Auto still exercises the shipping 1536 MiB
          # per-disk controller and the normal host-global admission path.
          integration_config="$RUNNER_TEMP/msb-integration-config.json"
          printf '%s\n' '{"runtime":{"block_writeback":{"mode":"auto","pool_mib":4096}}}' > "$integration_config"
          export MSB_CONFIG_PATH="$integration_config"
          cargo-nextest nextest run \
            --archive-file build/rust-integration-tests.tar.zst \
            --workspace-remap "${{ github.workspace }}" \
            --run-ignored=only \
            --test-threads 2

      - name: Disk usage
        if: always()
        run: scripts/ci/clean-runner-disk.sh

  # ---------------------------------------------------------------------------
  # Node.js SDK smoke tests (requires KVM)
  #
  # Runs the smoke suite under both Node and Bun as separate matrix cells.
  # With twelve runner services available these start as soon as the runtime and
  # native Node artifact are ready; host capacity is the concurrency bound.
  #
  # No `~/.microsandbox` install: the SDK uses its bundled platform-pkg
  # binaries. We patch in the freshly-built msb + libkrunfw so the test
  # exercises current code, not the lagged published binary. With no home
  # fallback, the bridge is the only path msb can reach native — under Bun
  # pre-fix, sandbox creation fails clean instead of being silently masked.
  # ---------------------------------------------------------------------------
  node-sdk-test:
    name: Node.js SDK Tests (${{ matrix.runtime }})
    needs: [build-linux-x86_64, node-sdk-build, changes]
    if: always() && needs.changes.outputs.code == 'true' && needs.build-linux-x86_64.result == 'success' && needs.node-sdk-build.result == 'success'
    runs-on: self-hosted-ubuntu-2404-x64
    # Bound a wedged run so it can't squat a shared runner for the 6h default.
    timeout-minutes: 45
    strategy:
      fail-fast: false
      max-parallel: 2
      matrix:
        runtime: [node, bun]
    steps:
      - name: Clean workspace
        run: |
          rm -rf "${{ github.workspace }}"/{sdk,build}
          rm -rf ~/.microsandbox

      - name: Clean runner disk
        run: |
          set -euo pipefail
          df -hT / /tmp "${GITHUB_WORKSPACE}" "${RUNNER_WORKSPACE}" || true
          rm -rf "${GITHUB_WORKSPACE}"/{sdk,build,target}
          rm -rf ~/.microsandbox
          find /tmp -mindepth 1 -maxdepth 1 -type d \
            \( -name 'msb-*' -o -name 'TestSandbox*' -o -name 'go-build*' \) \
            -mmin +120 -exec rm -rf {} + 2>/dev/null || true
          find /tmp -mindepth 1 -maxdepth 1 -type d \
            \( -name 'codex-*' -o -name 'microsandbox-*' -o -name 'libkrun-*' \) \
            -mmin +360 -exec rm -rf {} + 2>/dev/null || true
          find "${RUNNER_WORKSPACE}" -mindepth 1 -maxdepth 1 -type d \
            -name 'microsandbox*' -mmin +360 -exec rm -rf {} + 2>/dev/null || true
          df -hT / /tmp "${GITHUB_WORKSPACE}" "${RUNNER_WORKSPACE}" || true

      - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
        with:
          node-version: 22

      - name: Download build artifacts
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          name: msb-linux-x86_64
          path: build/

      - name: Download Node SDK artifacts
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          name: node-sdk-linux-x86_64
          path: sdk/node-ts/

      - name: Install Node.js dependencies
        working-directory: sdk/node-ts
        run: |
          node scripts/prune-platform-optional-deps.mjs
          npm install --package-lock=false --ignore-scripts

      # The published platform-pkg msb may lag the SDK; replace it with the
      # freshly-built binaries so the smoke test runs against current code.
      # Release-bump PRs can reference platform package versions that are not
      # published yet, so synthesize the package layout before patching it.
      - name: Use fresh runtime binaries in platform package
        working-directory: sdk/node-ts
        run: |
          PKG=node_modules/@superradcompany/microsandbox-linux-x64-gnu
          mkdir -p "$PKG/bin" "$PKG/lib"
          node - <<'NODE'
          const fs = require("node:fs");

          const pkgName = "@superradcompany/microsandbox-linux-x64-gnu";
          const root = JSON.parse(fs.readFileSync("package.json", "utf8"));
          const version = root.optionalDependencies?.[pkgName] ?? root.version;

          fs.writeFileSync(
            "node_modules/@superradcompany/microsandbox-linux-x64-gnu/package.json",
            `${JSON.stringify({
              name: pkgName,
              version,
              main: "microsandbox.linux-x64-gnu.node",
              os: ["linux"],
              cpu: ["x64"],
              libc: ["glibc"],
              license: "Apache-2.0",
              engines: { node: ">= 22" },
            }, null, 2)}\n`,
          );
          NODE
          cp native/microsandbox.linux-x64-gnu.node "$PKG/"
          chmod +x ${{ github.workspace }}/build/msb
          install -m755 ${{ github.workspace }}/build/msb "$PKG/bin/msb"
          rm -f "$PKG"/lib/libkrunfw*
          cp ${{ github.workspace }}/build/libkrunfw.so.${{ env.LIBKRUNFW_VERSION }} "$PKG/lib/"
          cd "$PKG/lib"
          ln -sf libkrunfw.so.${{ env.LIBKRUNFW_VERSION }} libkrunfw.so.${{ env.LIBKRUNFW_ABI }}
          ln -sf libkrunfw.so.${{ env.LIBKRUNFW_ABI }} libkrunfw.so

      - name: Run SDK tests (Node)
        if: matrix.runtime == 'node'
        working-directory: sdk/node-ts
        # Linux unix-socket paths are 108 bytes — anchor MSB_HOME under
        # /tmp with a cleanup-friendly prefix so sandboxes/<long-name>/runtime/agent.sock fits.
        run: |
          MSB_HOME=$(mktemp -d -p /tmp msb-node-XXXXXX)
          trap "rm -rf '$MSB_HOME'" EXIT
          export MSB_HOME
          npm test

      # setup-bun downloads a zipped release. The host provisioner installs
      # unzip so pull-request jobs never need sudo access.
      - name: Verify unzip is available
        if: matrix.runtime == 'bun'
        run: |
          if ! command -v unzip >/dev/null 2>&1; then
            echo "::error::unzip is missing; rerun scripts/ci/provision-runners.sh on the host"
            exit 1
          fi

      - if: matrix.runtime == 'bun'
        uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2

      - name: Run SDK tests (Bun)
        if: matrix.runtime == 'bun'
        working-directory: sdk/node-ts
        # Linux unix-socket paths are 108 bytes — anchor MSB_HOME under
        # /tmp with a cleanup-friendly prefix so sandboxes/<long-name>/runtime/agent.sock fits.
        run: |
          MSB_HOME=$(mktemp -d -p /tmp msb-node-XXXXXX)
          trap "rm -rf '$MSB_HOME'" EXIT
          export MSB_HOME
          bunx --bun vitest run

      - name: Disk usage
        if: always()
        run: |
          df -hT / /tmp "${GITHUB_WORKSPACE}" "${RUNNER_WORKSPACE}" || true
          du -xhd1 /tmp 2>/dev/null | sort -h | tail -30 || true

  # ---------------------------------------------------------------------------
  # Python SDK integration tests (requires KVM)
  #
  # Downloads the pre-built msb + libkrunfw and the ABI3 wheel, then runs the
  # integration suite against those exact artifacts without compiling on KVM.
  # ---------------------------------------------------------------------------
  python-sdk-test:
    name: Python SDK Tests
    needs: [build-linux-x86_64, python-wheel-build, changes]
    if: always() && needs.changes.outputs.code == 'true' && needs.build-linux-x86_64.result == 'success' && needs.python-wheel-build.result == 'success'
    runs-on: self-hosted-ubuntu-2404-x64
    # Bound a wedged run so it can't squat a shared runner for the 6h default.
    timeout-minutes: 45
    steps:
      - name: Clean workspace
        run: |
          rm -rf "${{ github.workspace }}/build" "${{ github.workspace }}/sdk/python/.venv"
          rm -rf ~/.microsandbox

      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

      - name: Clean runner disk
        run: scripts/ci/clean-runner-disk.sh

      - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
        with:
          enable-cache: true
          cache-dependency-glob: "sdk/python/uv.lock"

      - name: Download build artifacts
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          name: msb-linux-x86_64
          path: build/

      - name: Download Python wheel
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          name: python-wheel-linux-x86_64
          path: sdk/python/dist/

      - name: Install Python development environment
        working-directory: sdk/python
        run: |
          uv sync --group dev --no-install-project
          uv pip install --reinstall dist/*.whl

      - name: Run Python integration tests
        env:
          MSB_PATH: ${{ github.workspace }}/build/msb
          LD_LIBRARY_PATH: ${{ github.workspace }}/build
        run: |
          chmod +x "${MSB_PATH}"
          MSB_HOME=$(mktemp -d -p /tmp msb-python-XXXXXX)
          trap 'rm -rf "$MSB_HOME"' EXIT
          export MSB_HOME
          uv run --project sdk/python --no-sync pytest --import-mode=importlib -n 2 --dist loadscope sdk/python/integration

      - name: Disk usage
        if: always()
        run: scripts/ci/clean-runner-disk.sh

  # ---------------------------------------------------------------------------
  # Python SDK lower-bound check (Python 3.10)
  #
  # Every other Python job runs uv unpinned, so it always resolves a modern
  # interpreter and never exercises the declared floor in pyproject.toml
  # (requires-python = ">=3.10"). That let 3.11-only code ship unimportable
  # on 3.10 (#1153). This job installs the shared cp310-abi3 wheel under a
  # pinned 3.10 interpreter, so the support claim stays tested without a
  # redundant native build.
  # ---------------------------------------------------------------------------
  python-sdk-lower-bound:
    name: Python SDK (3.10 lower bound)
    needs: [python-wheel-build, changes]
    if: needs.changes.outputs.code == 'true'
    runs-on: ubuntu-latest
    timeout-minutes: 20
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

      - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
        with:
          enable-cache: true
          cache-dependency-glob: "sdk/python/uv.lock"
          python-version: "3.10"

      - name: Download Python wheel
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          name: python-wheel-linux-x86_64
          path: sdk/python/dist/

      - name: Sync Python dev environment
        working-directory: sdk/python
        run: |
          uv sync --group dev --no-install-project
          uv pip install --reinstall dist/*.whl

      # Guard against the pin silently not applying — an unpinned resolve
      # is exactly the failure mode this job exists to prevent.
      - name: Verify interpreter is 3.10
        run: uv run --project sdk/python --no-sync python -c "import sys; assert sys.version_info[:2] == (3, 10), sys.version"

      - name: Test Python SDK
        run: uv run --project sdk/python --no-sync pytest sdk/python/tests

  # ---------------------------------------------------------------------------
  # Go SDK integration tests (requires KVM)
  #
  # Downloads the pre-built msb + libkrunfw + libmicrosandbox_go_ffi.so from
  # the runtime job, then runs `go test -tags integration ./integration/...`.
  # Mirrors the node-sdk-test pattern.
  # ---------------------------------------------------------------------------
  go-sdk-test:
    name: Go SDK Tests (shard ${{ matrix.label }})
    needs: [build-linux-x86_64, go-ffi-build, changes]
    if: needs.changes.outputs.code == 'true'
    runs-on: self-hosted-ubuntu-2404-x64
    # Bound a wedged run so it can't squat a shared runner for the 6h default.
    timeout-minutes: 45
    strategy:
      fail-fast: false
      max-parallel: 2
      matrix:
        include:
          - shard: 0
            total: 2
            label: 1/2
          - shard: 1
            total: 2
            label: 2/2
    steps:
      - name: Clean workspace
        run: |
          rm -rf "${{ github.workspace }}"/build
          rm -rf ~/.microsandbox

      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

      - name: Clean runner disk
        run: scripts/ci/clean-runner-disk.sh

      - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
        with:
          go-version: stable
          # No cache-dependency-path: sdk/go has zero external deps,
          # nothing to cache.
          cache: false

      # -- Download pre-built artifacts (msb + libkrunfw + cdylib all in build/) --
      - name: Download build artifacts
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          name: msb-linux-x86_64
          path: build/

      - name: Download Go FFI artifact
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          name: go-ffi-linux-x86_64
          path: build/

      # -- Install msb + libkrunfw --
      - name: Install msb
        run: |
          chmod +x build/msb
          mkdir -p ~/.microsandbox/bin ~/.microsandbox/lib
          install -m755 build/msb ~/.microsandbox/bin/msb
          install -m644 build/libkrunfw.so.${{ env.LIBKRUNFW_VERSION }} ~/.microsandbox/lib/
          ln -sf libkrunfw.so.${{ env.LIBKRUNFW_VERSION }} ~/.microsandbox/lib/libkrunfw.so.${{ env.LIBKRUNFW_ABI }}
          ln -sf libkrunfw.so.${{ env.LIBKRUNFW_ABI }} ~/.microsandbox/lib/libkrunfw.so

      # -- Run integration tests --
      - name: Run Go integration tests
        working-directory: sdk/go
        env:
          # SDK builds embed the FFI; the microsandbox_ffi_path build tag
          # swaps the embed for a reader of this env var so we test
          # against the freshly-built local .so.
          MICROSANDBOX_FFI_PATH: ${{ github.workspace }}/build/libmicrosandbox_go_ffi.so
          # Force the SDK to spawn the freshly-built msb (mirrors node-sdk-test
          # reasoning: avoid running against a stale bundled binary).
          MSB_PATH: ${{ github.workspace }}/build/msb
        # Linux unix-socket paths are 108 bytes — anchor MSB_HOME under
        # /tmp with a cleanup-friendly prefix so sandboxes/<long-name>/runtime/agent.sock fits.
        run: |
          set -euo pipefail

          MSB_HOME=$(mktemp -d -p /tmp msb-go-${{ matrix.shard }}-XXXXXX)
          trap "rm -rf '$MSB_HOME'" EXIT
          export MSB_HOME
          export PATH="$HOME/.microsandbox/bin:$PATH"
          export LD_LIBRARY_PATH="${{ github.workspace }}/build:$HOME/.microsandbox/lib"

          mapfile -t all_tests < <(
            go test -tags "integration microsandbox_ffi_path" -list '^Test' ./integration \
              | sed -n '/^Test/p'
          )
          selected_tests=()
          for index in "${!all_tests[@]}"; do
            if (( index % ${{ matrix.total }} == ${{ matrix.shard }} )); then
              selected_tests+=("${all_tests[$index]}")
            fi
          done
          if (( ${#selected_tests[@]} == 0 )); then
            echo "no Go integration tests selected for shard ${{ matrix.label }}" >&2
            exit 1
          fi

          test_pattern=$(IFS='|'; echo "${selected_tests[*]}")
          echo "running ${#selected_tests[@]} Go integration tests in shard ${{ matrix.label }}"
          status=0
          go test -v -tags "integration microsandbox_ffi_path" -count=1 -timeout 45m \
            -run "^(${test_pattern})$" ./integration || status=$?

          if [ "$status" -ne 0 ]; then
            echo "::group::Go sandbox runtime logs without core.ready"
            find "$MSB_HOME/sandboxes" -path '*/logs/runtime.log' -type f -print0 2>/dev/null \
              | while IFS= read -r -d '' log; do
                  if ! grep -q 'agent relay: received core.ready' "$log"; then
                    echo "--- $log"
                    tail -120 "$log" || true
                  fi
                done
            echo "::endgroup::"
          fi

          exit "$status"

      - name: Disk usage
        if: always()
        run: scripts/ci/clean-runner-disk.sh

  # ---------------------------------------------------------------------------
  # Aggregator: a single status check that always reports, regardless of
  # whether the matrix jobs ran. Branch protection requires only this job,
  # so docs-only PRs (where the matrix is skipped) still satisfy the gate.
  # ---------------------------------------------------------------------------
  test-summary:
    name: Test Summary
    if: always()
    needs:
      - changes
      - test-linux-x86_64
      - check
    runs-on: ubuntu-latest
    steps:
      - name: Aggregate results
        run: |
          results='${{ toJson(needs.*.result) }}'
          echo "needs results: $results"
          if echo "$results" | grep -qE '"failure"|"cancelled"'; then
            echo "::error::A required test job failed or was cancelled"
            exit 1
          fi
          echo "All required test jobs passed or were skipped"

  summary:
    name: Check Summary
    if: always()
    needs:
      - changes
      - build-kernel
      - build-kernel-x86_64
      - build-agentd-aarch64
      - build-agentd-x86_64
      - build-linux-x86_64
      - go-ffi-build
      - rust-integration-build
      - check
      - windows-quality
      - windows-build
      - rust-quality
      - node-sdk-build
      - python-wheel-build
      - python-quality
      - go-quality
      - cli-smoke-test
      - integration-test
      - node-sdk-test
      - python-sdk-test
      - python-sdk-lower-bound
      - go-sdk-test
      - test-summary
    runs-on: ubuntu-latest
    steps:
      - name: Aggregate results
        run: |
          results='${{ toJson(needs.*.result) }}'
          echo "needs results: $results"
          if echo "$results" | grep -qE '"failure"|"cancelled"'; then
            echo "::error::A required job failed or was cancelled"
            exit 1
          fi
          echo "All required jobs passed or were skipped"
