name: Check

on:
  # Qualify an exact branch revision for internal commit-image publication,
  # including stacked PRs whose base is not main. This workflow publishes nothing.
  workflow_dispatch:
  push:
    branches: [main, "releases/**"]
  pull_request:
    branches:
      - main
      - mintlify
      - "releases/**"

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 }}
      ruby: ${{ steps.filter.outputs.ruby }}
      ruby_package: ${{ steps.filter.outputs.ruby_package }}
      ruby_platform: ${{ steps.filter.outputs.ruby_platform }}
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
        id: filter
        with:
          filters: |
            code:
              - '!docs/**'
            ruby:
              - '.github/workflows/check.yml'
              - 'Cargo.lock'
              - 'Cargo.toml'
              - 'crates/**'
              - 'packages/**'
              - 'scripts/ci/ruby-sdk-checkout.sh'
              - 'scripts/ci/check-ruby-standalone-pin.sh'
              - 'sdk/ruby/**'
              - 'sdk/rust/**'
            ruby_package:
              - '.github/workflows/check.yml'
              - 'Cargo.lock'
              - 'Cargo.toml'
              - 'scripts/ci/ruby-sdk-checkout.sh'
              - 'scripts/ci/check-ruby-standalone-pin.sh'
              - 'sdk/ruby/Rakefile'
              - 'sdk/ruby/ext/microsandbox/Cargo.lock'
              - 'sdk/ruby/ext/microsandbox/Cargo.toml'
              - 'sdk/ruby/ext/microsandbox/extconf.rb'
              - 'sdk/ruby/lib/microsandbox.rb'
              - 'sdk/ruby/lib/microsandbox/version.rb'
              - 'sdk/ruby/microsandbox.gemspec'
              - 'sdk/rust/Cargo.toml'
            ruby_platform:
              - '.github/workflows/check.yml'
              - 'Cargo.lock'
              - 'Cargo.toml'
              - 'sdk/ruby/Rakefile'
              - 'sdk/ruby/ext/**'
              - 'sdk/ruby/lib/microsandbox.rb'
              - 'sdk/ruby/lib/microsandbox/version.rb'
              - 'sdk/ruby/microsandbox.gemspec'
              - 'sdk/rust/**'

  # ---------------------------------------------------------------------------
  # Keep TypeScript as the default SDK in generic documentation surfaces.
  # ---------------------------------------------------------------------------
  docs-language-order:
    name: Docs language order
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - name: Check TypeScript-first documentation
        run: python3 scripts/check-docs-language-order.py

  release-notes:
    name: Release note contracts
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
        with:
          node-version: "24"
      - name: Check release note contracts
        run: |
          python3 -m venv "$RUNNER_TEMP/release-note-checks"
          "$RUNNER_TEMP/release-note-checks/bin/pip" install PyYAML==6.0.3
          PYTHON="$RUNNER_TEMP/release-note-checks/bin/python" node --test scripts/ci/test-release-notes.cjs

  # ---------------------------------------------------------------------------
  # 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: scripts/ci/install-apt-packages.sh 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: scripts/ci/install-apt-packages.sh 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@f0d9c3887740aee45f6153b24b3a6b815192ec16 # v2
        with:
          cache-bin: false
          cache-targets: true

      - name: Install agentd build deps
        run: scripts/ci/install-apt-packages.sh 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@f0d9c3887740aee45f6153b24b3a6b815192ec16 # v2
        with:
          cache-bin: false
          cache-targets: true

      - name: Install agentd build deps
        run: scripts/ci/install-apt-packages.sh 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@f0d9c3887740aee45f6153b24b3a6b815192ec16 # v2
        with:
          cache-bin: false
          cache-targets: true

      - name: Install build deps
        run: scripts/ci/install-apt-packages.sh 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@10ca16bf91dc22e05ebdc935cad9c75ea248f621 # 2.41.0

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

      - name: Smoke test installed runtime discovery
        run: |
          python3 scripts/smoke/cli/runtime-discovery.py \
            --msb build/msb --libkrunfw build/libkrunfw.so.${{ env.LIBKRUNFW_VERSION }}

      # 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@f0d9c3887740aee45f6153b24b3a6b815192ec16 # v2
        with:
          cache-bin: false
          cache-targets: true

      - name: Install build deps
        run: scripts/ci/install-apt-packages.sh 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@10ca16bf91dc22e05ebdc935cad9c75ea248f621 # 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@f0d9c3887740aee45f6153b24b3a6b815192ec16 # v2
        with:
          cache-bin: false
          cache-targets: true

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

      - name: Set up mold
        uses: rui314/setup-mold@10ca16bf91dc22e05ebdc935cad9c75ea248f621 # 2.41.0

      - name: Install build dependencies
        run: scripts/ci/install-apt-packages.sh 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.
          # The KVM job supplies both the host runtime and Agentd explicitly,
          # so this archive needs neither download nor embedded binary features.
          mold -run cargo nextest archive -p microsandbox --tests \
            --no-default-features --features keyring,local,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 and host-filesystem unit tests.
  #
  # 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"
      # This lane intentionally disables embedding and exercises the downloaded
      # target-matched guest payload through the runtime override instead.
      MSB_AGENTD_PATH: ${{ github.workspace }}/build/agentd
    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@f0d9c3887740aee45f6153b24b3a6b815192ec16 # 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,runner -p microsandbox-runtime --lib --target ${{ matrix.rust_target }} test_bind_rootfs_backend_exposes_host_file_and_init

      - name: Test bind rootfs patches
        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 local,net -p microsandbox --lib --target ${{ matrix.rust_target }} sandbox::patch::tests::bind_patch_

      - name: Test host paths, metrics registries, and confined volumes
        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 local,net -p microsandbox --lib --target ${{ matrix.rust_target }} host_paths
          if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
          cargo +stable test --no-default-features --features local,net -p microsandbox --lib --target ${{ matrix.rust_target }} config_paths
          if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
          cargo +stable test --no-default-features --features local,net -p microsandbox --lib --target ${{ matrix.rust_target }} metrics_lookup
          if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
          cargo +stable test --no-default-features --features local,net -p microsandbox --lib --target ${{ matrix.rust_target }} volume::fs

      - name: Test Windows lifecycle handoff and stdio cleanup
        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 local,net -p microsandbox --lib --target ${{ matrix.rust_target }} runtime::spawn::tests::windows_

      - name: Test Windows DNS resolver
        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 -p microsandbox-network --lib --target ${{ matrix.rust_target }} dns::windows_resolver::

      - name: Test Windows filesystem DAX mappings
        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 -p microsandbox-filesystem --lib --target ${{ matrix.rust_target }} dax_

  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@f0d9c3887740aee45f6153b24b3a6b815192ec16 # 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

      # Go's Rust binding enables download-binaries. Build and stage the same-source
      # runtime in both lanes so an unreleased version never needs release assets.
      - name: Build msb
        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 embed-binaries,net,ssh -p microsandbox-cli --target ${{ matrix.rust_target }}
          if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
          Copy-Item "target\${{ matrix.rust_target }}\ci\msb.exe" build\msb.exe -Force

      - name: Smoke test installed runtime discovery
        if: matrix.component == 'CLI'
        shell: pwsh
        run: python scripts/smoke/cli/runtime-discovery.py --msb build/msb.exe --libkrunfw build/libkrunfw.dll

      - name: Build Go FFI cdylib
        if: matrix.component == 'Go'
        shell: pwsh
        env:
          RUSTFLAGS: -C target-feature=+crt-static
          MSB_EMBED_ARTIFACTS_DIR: ${{ github.workspace }}/build
        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 .
          if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
          $env:MICROSANDBOX_FFI_PATH = "$env:GITHUB_WORKSPACE\target\${{ matrix.rust_target }}\ci\microsandbox_go_ffi.dll"
          go test -tags microsandbox_ffi_path -run TestVolumeFs -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@f0d9c3887740aee45f6153b24b3a6b815192ec16 # v2
        with:
          cache-bin: false
          cache-targets: true

      - name: Install build deps
        run: scripts/ci/install-apt-packages.sh 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@10ca16bf91dc22e05ebdc935cad9c75ea248f621 # 2.41.0

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

      - name: Check Rust SDK feature surfaces
        run: |
          cargo +stable test -p microsandbox --no-default-features --features cloud,net --test api_compat
          cargo +stable check -p microsandbox --no-default-features --features local,net
          cargo +stable check -p microsandbox-runtime --no-default-features --features client,net
          cargo +stable check -p microsandbox-network --no-default-features
          cargo +stable check -p microsandbox-types --features utoipa

      - 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@f0d9c3887740aee45f6153b24b3a6b815192ec16 # v2
        with:
          cache-bin: false
          cache-targets: true

      - name: Set up mold
        uses: rui314/setup-mold@10ca16bf91dc22e05ebdc935cad9c75ea248f621 # 2.41.0

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

      - name: Install system dependencies
        run: scripts/ci/install-apt-packages.sh 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 shared protocol, agent, control, and types packages
        working-directory: packages
        run: npm ci && npm run build && npm run typecheck && npm test

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

      - name: Build and test 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 test

      - name: Upload Node SDK artifacts
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: node-sdk-linux-x86_64
          path: |
            sdk/node-ts/bin/microsandbox.cjs
            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

      # Artifact-only SDK jobs need the workspace dependency beside sdk/node-ts
      # so npm's existing workspace link resolves to the tested build.
      - name: Upload shared types artifacts
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: node-sdk-shared-types
          path: |
            packages/microsandbox-types/typescript/package.json
            packages/microsandbox-types/typescript/dist/

  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' }}
    # Healthy runs complete in roughly three minutes. Leave ample build
    # headroom while preventing a bad hosted runner from blocking every
    # downstream Python lane for most of an hour.
    timeout-minutes: 15
    env:
      CARGO_INCREMENTAL: "0"
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

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

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

      - name: Set up mold
        uses: rui314/setup-mold@10ca16bf91dc22e05ebdc935cad9c75ea248f621 # 2.41.0

      - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
        with:
          enable-cache: true
          cache-dependency-glob: "sdk/python/uv.lock"

      - name: Install system dependencies
        run: scripts/ci/install-apt-packages.sh 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
          # Match the release wheel after artifact download strips executable bits.
          install -m 755 build/msb sdk/python/microsandbox/_bundled/bin/msb
          install -m 644 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@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.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

  ruby-sdk:
    name: Ruby ${{ matrix.ruby }} / Linux x86_64
    needs: [build-agentd-x86_64, changes]
    if: needs.changes.outputs.ruby == 'true'
    runs-on: ${{ vars.CI_LINUX_X86_RUNNER || 'ubuntu-latest' }}
    timeout-minutes: 20
    strategy:
      fail-fast: false
      matrix:
        ruby: ["3.3", "3.4", "4.0"]
    env:
      CARGO_INCREMENTAL: "0"
      CARGO_TARGET_DIR: ${{ github.workspace }}/target/ruby-${{ matrix.ruby }}
      RB_SYS_CARGO_PROFILE: ci
      RB_SYS_CARGO_TARGET_DIR: ${{ github.workspace }}/target/ruby-${{ matrix.ruby }}
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

      - uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1
        with:
          ruby-version: ${{ matrix.ruby }}

      - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
        with:
          components: clippy, rustfmt

      - name: Use checkout Rust SDK
        run: scripts/ci/ruby-sdk-checkout.sh prepare

      - uses: Swatinem/rust-cache@f0d9c3887740aee45f6153b24b3a6b815192ec16 # v2
        with:
          workspaces: . -> target/ruby-${{ matrix.ruby }}
          key: ruby-${{ matrix.ruby }}-ci
          cache-bin: false
          cache-targets: true

      - name: Set up mold
        uses: rui314/setup-mold@10ca16bf91dc22e05ebdc935cad9c75ea248f621 # 2.41.0

      - name: Install system 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: Install Ruby build dependencies
        run: gem install rake rake-compiler test-unit rb_sys --no-document

      - name: Check Rust extension
        if: matrix.ruby == '4.0'
        working-directory: sdk/ruby/ext/microsandbox
        run: |
          cargo fmt -- --check
          cargo clippy --profile ci -- -D warnings

      - name: Compile and test source tree
        working-directory: sdk/ruby
        run: mold -run rake test

      - name: Restore standalone Ruby lockfile
        if: always()
        run: scripts/ci/ruby-sdk-checkout.sh restore

  ruby-source-gem:
    name: Ruby source gem / 4.0
    needs: [build-agentd-x86_64, changes]
    if: needs.changes.outputs.ruby == 'true' && (github.event_name == 'push' || needs.changes.outputs.ruby_package == 'true')
    runs-on: ${{ vars.CI_LINUX_X86_RUNNER || 'ubuntu-latest' }}
    timeout-minutes: 20
    env:
      CARGO_INCREMENTAL: "0"
      CARGO_TARGET_DIR: ${{ github.workspace }}/target/ruby-source-gem-4.0
      RB_SYS_CARGO_TARGET_DIR: ${{ github.workspace }}/target/ruby-source-gem-4.0
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

      - uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1
        with:
          ruby-version: "4.0"

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

      - name: Use checkout Rust SDK
        run: scripts/ci/ruby-sdk-checkout.sh prepare

      - uses: Swatinem/rust-cache@f0d9c3887740aee45f6153b24b3a6b815192ec16 # v2
        with:
          workspaces: . -> target/ruby-source-gem-4.0
          key: ruby-4.0-source-gem
          cache-bin: false
          cache-targets: true

      - name: Set up mold
        uses: rui314/setup-mold@10ca16bf91dc22e05ebdc935cad9c75ea248f621 # 2.41.0

      - name: Install system 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: Install Ruby build dependencies
        run: gem install rb_sys --no-document

      # The temporary Cargo patch is outside the gem, so the artifact keeps its
      # exact crates.io pin while this installation uses the matching local API.
      - name: Build and install source gem against checkout
        working-directory: sdk/ruby
        run: |
          gem build microsandbox.gemspec
          mold -run gem install --local microsandbox-*.gem --no-document
          expected_version=$(ruby -r ./lib/microsandbox/version -e 'print Microsandbox::VERSION')
          ruby --disable-gems -e 'require "rubygems"; require "microsandbox"; abort unless Microsandbox.version == ARGV.fetch(0)' "$expected_version"

      - name: Restore standalone Ruby lockfile
        if: always()
        run: scripts/ci/ruby-sdk-checkout.sh restore

  # ---------------------------------------------------------------------------
  # Check the shipped lockfile against crates.io; other Ruby jobs use the
  # SDK in this checkout and replace the standalone lockfile.
  # ---------------------------------------------------------------------------
  ruby-standalone-pin:
    name: Ruby standalone crates.io pin
    needs: [changes]
    if: needs.changes.outputs.ruby == 'true' && (github.event_name == 'push' || needs.changes.outputs.ruby_package == 'true')
    runs-on: ${{ vars.CI_LINUX_X86_RUNNER || 'ubuntu-latest' }}
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

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

      - uses: Swatinem/rust-cache@f0d9c3887740aee45f6153b24b3a6b815192ec16 # v2
        with:
          workspaces: sdk/ruby/ext/microsandbox -> target
          key: ruby-standalone-pin
          cache-bin: false
          cache-targets: false

      - name: Check the standalone Ruby SDK pin
        run: scripts/ci/check-ruby-standalone-pin.sh

  ruby-platform-extension:
    name: Build Ruby extension / ${{ matrix.platform }} / Ruby ${{ matrix.ruby }}
    needs: [build-agentd-aarch64, build-agentd-x86_64, changes]
    if: needs.changes.outputs.ruby_platform == 'true'
    runs-on: ${{ matrix.runner }}
    timeout-minutes: 45
    defaults:
      run:
        shell: bash
    strategy:
      fail-fast: false
      matrix:
        platform: [x86_64-linux-gnu, aarch64-linux-gnu, arm64-darwin, x64-mingw-ucrt]
        ruby: ["3.3", "3.4", "4.0"]
        include:
          # The platform label carries no glibc version, so the build host is
          # the compatibility floor. Keep the audit below in sync with these
          # runners when upgrading them.
          - platform: x86_64-linux-gnu
            runner: ubuntu-22.04
            agentd: x86_64
          - platform: aarch64-linux-gnu
            runner: ubuntu-22.04-arm
            agentd: aarch64
          - platform: arm64-darwin
            runner: macos-14
            agentd: aarch64
          # RubyInstaller uses MinGW-ucrt, so this needs the windows-gnu Rust
          # target rather than the MSVC target used by the rest of Windows CI.
          - platform: x64-mingw-ucrt
            runner: windows-latest
            agentd: x86_64
    env:
      CARGO_INCREMENTAL: "0"
      CARGO_TARGET_DIR: ${{ github.workspace }}/target/ruby-platform-${{ matrix.platform }}-${{ matrix.ruby }}
      RB_SYS_CARGO_TARGET_DIR: ${{ github.workspace }}/target/ruby-platform-${{ matrix.platform }}-${{ matrix.ruby }}
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

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

      - uses: Swatinem/rust-cache@f0d9c3887740aee45f6153b24b3a6b815192ec16 # v2
        with:
          workspaces: . -> target/ruby-platform-${{ matrix.platform }}-${{ matrix.ruby }}
          key: ruby-platform-${{ matrix.platform }}-${{ matrix.ruby }}
          cache-bin: false
          cache-targets: true

      - name: Install system dependencies
        if: contains(matrix.platform, 'linux')
        run: sudo apt-get update && sudo apt-get install -y libcap-ng-dev

      - name: Add the windows-gnu Rust target
        if: contains(matrix.platform, 'mingw')
        run: rustup target add x86_64-pc-windows-gnu

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

      - uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1
        if: "!contains(matrix.platform, 'mingw')"
        with:
          ruby-version: ${{ matrix.ruby }}

      - uses: oxidize-rb/actions/setup-ruby-and-rust@ec90b4aa48795518d79be3f5cc478d1d7ccd02a8 # v1.5.0
        if: contains(matrix.platform, 'mingw')
        with:
          ruby-version: ${{ matrix.ruby }}
          cargo-cache: false

      - name: Build and stage the extension
        working-directory: sdk/ruby
        run: |
          gem install rake rake-compiler rb_sys --no-document
          rake cargo:patch_workspace version_check gem:stage

      - name: Assert the extension uses the checkout Rust SDK
        working-directory: sdk/ruby
        run: cargo tree --manifest-path ext/microsandbox/Cargo.toml --depth 1 | grep "sdk.rust)"

      - name: Audit the glibc symbol floor
        if: contains(matrix.platform, 'linux')
        working-directory: sdk/ruby
        run: |
          for so in lib/microsandbox/*/microsandbox.so; do
            max=$(objdump -T "$so" | grep -oE 'GLIBC_[0-9]+\.[0-9]+' | sort -uV | tail -n1)
            echo "$so requires $max"
            if [ "$(printf '%s\nGLIBC_2.35\n' "$max" | sort -V | tail -n1)" != "GLIBC_2.35" ]; then
              echo "::error::$so requires $max, above the documented glibc 2.35 floor"
              exit 1
            fi
          done

      - name: Upload the staged extension
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: ruby-extension-${{ matrix.platform }}-${{ matrix.ruby }}
          # Preserve the Ruby version directory when merging the artifacts.
          path: sdk/ruby/lib/microsandbox/*/microsandbox.*
          if-no-files-found: error

      - name: Restore standalone Ruby lockfile
        if: always()
        working-directory: sdk/ruby
        run: rake cargo:unpatch_workspace

  ruby-platform-gem:
    name: Package Ruby platform gem / ${{ matrix.platform }}
    needs: [ruby-platform-extension, changes]
    if: needs.changes.outputs.ruby_platform == 'true'
    runs-on: ${{ matrix.runner }}
    timeout-minutes: 10
    defaults:
      run:
        shell: bash
    strategy:
      fail-fast: false
      matrix:
        include:
          - platform: x86_64-linux-gnu
            runner: ubuntu-22.04
          - platform: aarch64-linux-gnu
            runner: ubuntu-22.04-arm
          - platform: arm64-darwin
            runner: macos-14
          - platform: x64-mingw-ucrt
            runner: windows-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

      - uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1
        with:
          ruby-version: "4.0"

      - name: Install Ruby packaging dependencies
        run: gem install rake rake-compiler --no-document

      - name: Download the staged extensions
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          pattern: ruby-extension-${{ matrix.platform }}-*
          path: sdk/ruby/lib/microsandbox/
          merge-multiple: true

      - name: Package the platform gem
        working-directory: sdk/ruby
        env:
          GEM_PLATFORM: ${{ matrix.platform }}
        run: rake gem:platform

      - name: Assert the packaged gem spec
        working-directory: sdk/ruby
        env:
          GEM_PLATFORM: ${{ matrix.platform }}
        run: |
          ruby -e 'require "rubygems/package"

            path = Dir["pkg/*.gem"].first
            abort "no platform gem in pkg/" unless path
            spec = Gem::Package.new(path).spec

            abort "platform mismatch: #{spec.platform}" unless spec.platform.to_s == ENV.fetch("GEM_PLATFORM")
            abort "platform gem must not declare extensions: #{spec.extensions.inspect}" unless spec.extensions.empty?
            abort "required_ruby_version: #{spec.required_ruby_version}" unless spec.required_ruby_version == Gem::Requirement.new([">= 3.3", "< 4.1.dev"])
            abort "required_rubygems_version: #{spec.required_rubygems_version}" unless spec.required_rubygems_version == Gem::Requirement.new(">= 3.3.11")
            abort "rb_sys must not be a runtime dependency" if spec.dependencies.any? { |d| d.name == "rb_sys" && d.type == :runtime }

            %w[3.3 3.4 4.0].each do |abi|
              expected = ["lib/microsandbox/#{abi}/microsandbox.so", "lib/microsandbox/#{abi}/microsandbox.bundle"]
              abort "missing binary for Ruby #{abi}: #{spec.files.inspect}" if (spec.files & expected).empty?
            end

            puts "platform gem spec OK: #{File.basename(path)}"'

      - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: ruby-sdk-${{ matrix.platform }}
          path: sdk/ruby/pkg/*.gem

  ruby-platform-smoke:
    name: Smoke Ruby platform gem / ${{ matrix.platform }} / Ruby ${{ matrix.ruby }}
    needs: [changes, ruby-platform-gem]
    if: needs.changes.outputs.ruby_platform == 'true'
    runs-on: ${{ matrix.runner }}
    timeout-minutes: 10
    defaults:
      run:
        shell: bash
    strategy:
      fail-fast: false
      matrix:
        platform: [x86_64-linux-gnu, aarch64-linux-gnu, arm64-darwin, x64-mingw-ucrt]
        ruby: ["3.3", "3.4", "4.0"]
        include:
          - platform: x86_64-linux-gnu
            runner: ubuntu-22.04
          - platform: aarch64-linux-gnu
            runner: ubuntu-22.04-arm
          - platform: arm64-darwin
            runner: macos-14
          - platform: x64-mingw-ucrt
            runner: windows-latest
    steps:
      - uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1
        with:
          ruby-version: ${{ matrix.ruby }}

      - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          name: ruby-sdk-${{ matrix.platform }}

      - name: Install the platform gem
        run: gem install --local ./microsandbox-*.gem --no-document

      - name: Assert the precompiled binary is what loads
        run: |
          # shellcheck disable=SC2016
          ruby -e 'require "microsandbox"
            loaded = $LOADED_FEATURES.grep(%r{microsandbox/\d+\.\d+/microsandbox\.(so|bundle)$})
            abort "precompiled ABI binary not used: #{$LOADED_FEATURES.grep(/microsandbox/).inspect}" if loaded.empty?
            abort "native/ruby version mismatch" unless Microsandbox.version == Microsandbox::VERSION'

  ruby-platform-smoke-clean:
    name: Smoke Ruby platform gem / x86_64-linux-gnu / clean container
    needs: [changes, ruby-platform-gem]
    if: needs.changes.outputs.ruby_platform == 'true'
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          name: ruby-sdk-x86_64-linux-gnu

      - name: Install and load the gem in a stock Ruby image
        run: |
          docker run --rm -v "$PWD:/gems" ruby:3.4-slim bash -ec 'gem install --local /gems/microsandbox-*.gem --no-document && ruby -e "require \"microsandbox\"
          loaded = \$LOADED_FEATURES.grep(%r{microsandbox/\d+\.\d+/microsandbox\.(so|bundle)\$})
          abort \"precompiled ABI binary not used: #{\$LOADED_FEATURES.grep(/microsandbox/).inspect}\" if loaded.empty?
          abort \"native/ruby version mismatch\" unless Microsandbox.version == Microsandbox::VERSION"'

  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 .

  # ---------------------------------------------------------------------------
  # Previous-release upgrade smoke test
  #
  # Creates databases with the two latest released CLIs, then opens them with
  # the candidate binary. This catches migration-order and compatibility gaps
  # without requiring KVM.
  # ---------------------------------------------------------------------------
  previous-release-upgrade-smoke:
    name: Previous Release Upgrade Smoke
    needs: [build-linux-x86_64, changes]
    if: needs.changes.outputs.code == 'true'
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

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

      - name: Test released database upgrades
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          chmod +x build/msb
          python3 -m unittest discover -s scripts/smoke/cli -p test_previous_release_upgrade.py -v
          python3 scripts/smoke/cli/previous_release_upgrade.py

  # ---------------------------------------------------------------------------
  # 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: CI helper and snapshot runner unit tests
        run: |
          python3 -m unittest discover -s scripts/ci -p test_bump_version.py
          python3 -m unittest discover -s scripts/ci -p test_runner_storage.py
          python3 -m unittest discover -s scripts/smoke/cli -p test_snapshot_branch.py
          python3 -m unittest discover -s scripts/smoke/cli -p test_restore_resource_intent.py
          python3 -m unittest discover -s scripts/smoke/cli -p test_catalog_history.py

      - name: Snapshot and branch live smoke
        # The short operation deadline excludes image setup and bounded cleanup.
        # Leave both layouts enough failure-path time to stop VMs and save evidence.
        timeout-minutes: 15
        env:
          MSB_LIBKRUNFW_PATH: ${{ github.workspace }}/build/libkrunfw.so.${{ env.LIBKRUNFW_VERSION }}
          MSB_AGENTD_PATH: ${{ github.workspace }}/build/agentd
          LD_LIBRARY_PATH: ${{ github.workspace }}/build
        run: |
          status=0
          # Short isolated homes avoid Unix socket limits and cross-layout reuse.
          # Run both even if one fails so the artifact reports retain both outcomes.
          for layout in managed flat; do
            python3 scripts/smoke/cli/snapshot-branch.py \
              --binary "${{ github.workspace }}/build/msb" \
              --output "/tmp/msb-smoke-${{ github.run_id }}-${{ github.run_attempt }}-$layout" \
              --layout "$layout" || status=$?
          done
          exit "$status"

      - name: Collect snapshot smoke reports and logs
        if: always()
        run: |
          # Pass a single bounded directory to upload-artifact; its /tmp glob inputs
          # failed with a stack overflow before any reports were uploaded.
          stage="${{ runner.temp }}/snapshot-branch-smoke-${{ github.run_id }}-${{ github.run_attempt }}"
          mkdir -p "$stage"
          for layout in managed flat; do
            source="/tmp/msb-smoke-${{ github.run_id }}-${{ github.run_attempt }}-$layout"
            destination="$stage/msb-smoke-${{ github.run_id }}-${{ github.run_attempt }}-$layout"
            if [ -f "$source/report.json" ]; then
              mkdir -p "$destination"
              cp -- "$source/report.json" "$destination/report.json"
            fi
            for log in "$source"/logs/*.log; do
              [ -f "$log" ] || continue
              mkdir -p "$destination/logs"
              cp -- "$log" "$destination/logs/"
            done
          done

      - name: Upload snapshot smoke reports and logs
        if: always()
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: snapshot-branch-smoke-linux-x86_64
          # Keep text evidence, not the potentially large guest RAM/disk artifacts.
          path: ${{ runner.temp }}/snapshot-branch-smoke-${{ github.run_id }}-${{ github.run_attempt }}
          if-no-files-found: warn

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

  # ---------------------------------------------------------------------------
  # 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@4cef1412cce204788f482e778a0b9187f9626a29 # 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"
          MSB_AGENTD_PATH: ${{ github.workspace }}/build/agentd
        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"
          # Catalog/control compatibility and binary-replacement fixtures require caller-selected
          # historical binaries, existing VMs, and dummy secrets. Keep them out of this
          # self-provisioning batch; the catalog fixture is provisioned separately below.
          cargo-nextest nextest run \
            --archive-file build/rust-integration-tests.tar.zst \
            --workspace-remap "${{ github.workspace }}" \
            --run-ignored=only \
            -E 'not (binary(=creation_progress_live) or binary(=eager_lifecycle_live) or test(/^backend::local::control::.*::live_/) or test(/^runtime::launch_contract::tests::released_/) or test(/^backend::local::database::tests::live_sdk_upgrades_catalog_with_previous_version_runtime$/))' \
            --test-threads 2

      # This fixture needs a released runtime, not the candidate installed above.
      # Keep its signal even when an unrelated test in the generic batch fails.
      - name: Historical catalog compatibility (v0.6.18)
        if: ${{ !cancelled() }}
        timeout-minutes: 10
        run: |
          python3 scripts/smoke/cli/catalog-history.py \
            --archive build/rust-integration-tests.tar.zst \
            --workspace "${{ github.workspace }}" \
            --cleanup-binary build/msb \
            --output build/catalog-history-results

      - name: Upload historical catalog results
        if: always()
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: catalog-history-results
          path: build/catalog-history-results/*.log
          if-no-files-found: ignore

      # These tests require a captured guest and scoped fault injection; run every case
      # explicitly, including all three eager modes, even if the general batch failed.
      - name: Checkpoint lifecycle integration fixtures
        if: ${{ !cancelled() }}
        run: |
          export LD_LIBRARY_PATH="${{ github.workspace }}/build:$HOME/.microsandbox/lib"
          python3 scripts/smoke/cli/checkpoint-integration.py \
            --binary build/msb --agent build/agentd \
            --firmware "build/libkrunfw.so.${{ env.LIBKRUNFW_VERSION }}" \
            --archive build/rust-integration-tests.tar.zst \
            --workspace "${{ github.workspace }}" \
            --output build/checkpoint-integration-results

      - name: Upload checkpoint lifecycle results
        if: always()
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: checkpoint-integration-results
          path: build/checkpoint-integration-results/
          if-no-files-found: ignore

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

  # ---------------------------------------------------------------------------
  # 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,packages/microsandbox-types/typescript}
          rm -rf ~/.microsandbox

      # Keep SDK tests artifact-only; fetch only the shared CI maintenance scripts.
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          path: .ci-source
          sparse-checkout: scripts/ci
          persist-credentials: false

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

      - 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: Download shared types artifacts
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          name: node-sdk-shared-types
          path: packages/microsandbox-types/typescript/

      - name: Install Node.js dependencies
        working-directory: sdk/node-ts
        run: |
          node scripts/prune-platform-optional-deps.mjs
          npm ci --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: bash .ci-source/scripts/ci/clean-runner-disk.sh --finish

  # ---------------------------------------------------------------------------
  # 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@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
        with:
          # Persistent runners already retain a bounded local cache. Restoring a
          # multi-GB Actions cache here can exhaust the shared disk before tests.
          enable-cache: false
          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 --finish

  # ---------------------------------------------------------------------------
  # 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@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.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/

      # -- 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

          # EnsureInstalled resolves the installation root from MSB_HOME. Seed
          # that exact directory so bump PRs use local, unpublished artifacts.
          mkdir -p "$MSB_HOME/bin" "$MSB_HOME/lib"
          chmod +x "${{ github.workspace }}/build/msb"
          install -m755 "${{ github.workspace }}/build/msb" "$MSB_HOME/bin/msb"
          install -m644 "${{ github.workspace }}/build/libkrunfw.so.${{ env.LIBKRUNFW_VERSION }}" "$MSB_HOME/lib/"
          ln -sf libkrunfw.so.${{ env.LIBKRUNFW_VERSION }} "$MSB_HOME/lib/libkrunfw.so.${{ env.LIBKRUNFW_ABI }}"
          ln -sf libkrunfw.so.${{ env.LIBKRUNFW_ABI }} "$MSB_HOME/lib/libkrunfw.so"
          export PATH="$MSB_HOME/bin:$PATH"
          export LD_LIBRARY_PATH="${{ github.workspace }}/build:$MSB_HOME/lib"

          if ! test_listing=$(go test -tags "integration microsandbox_ffi_path" -list '^Test' ./integration 2>&1); then
            printf '%s\n' "$test_listing" >&2
            exit 1
          fi
          mapfile -t all_tests < <(printf '%s\n' "$test_listing" | 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 --finish

  # ---------------------------------------------------------------------------
  # 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
      - docs-language-order
      - 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
      - docs-language-order
      - 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
      - ruby-sdk
      - ruby-source-gem
      - ruby-standalone-pin
      - ruby-platform-extension
      - ruby-platform-gem
      - ruby-platform-smoke
      - ruby-platform-smoke-clean
      - go-quality
      - previous-release-upgrade-smoke
      - 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"
