name: Release

on:
  push:
    tags: ["v*"]

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: false

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"

permissions:
  contents: write
  packages: write

jobs:
  # ---------------------------------------------------------------------------
  # Build kernel.c on Linux for macOS libkrunfw linking
  # ---------------------------------------------------------------------------
  build-kernel:
    name: Build kernel.c (aarch64)
    runs-on: ubuntu-24.04-arm
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          submodules: true

      - 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/release.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)
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          submodules: true

      - 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/release.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 host packages that cannot build the guest binary natively
  # ---------------------------------------------------------------------------
  build-agentd-aarch64:
    name: Build agentd (aarch64-linux-musl)
    runs-on: ubuntu-24.04-arm
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          submodules: true

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

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

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

      - name: Build agentd
        run: |
          rustup target add aarch64-unknown-linux-musl
          cargo build --release --manifest-path crates/agentd/Cargo.toml --target aarch64-unknown-linux-musl
          mkdir -p build
          cp target/aarch64-unknown-linux-musl/release/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)
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          submodules: true

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

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

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

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

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

  # ---------------------------------------------------------------------------
  # Build
  # ---------------------------------------------------------------------------
  build:
    name: Build (${{ matrix.target }})
    needs: [build-kernel, build-agentd-aarch64]
    if: always()
    runs-on: ${{ matrix.runner }}
    strategy:
      fail-fast: false
      matrix:
        include:
          - target: linux-x86_64
            runner: ubuntu-latest
            arch: x86_64
            os: linux
            agentd_target: x86_64-unknown-linux-musl
            libkrunfw_file: libkrunfw.so.5.6.1
            libkrunfw_asset: libkrunfw-linux-x86_64.so
            napi_target: x86_64-unknown-linux-gnu
            node_file: microsandbox.linux-x64-gnu.node
            npm_dir: linux-x64-gnu
          - target: linux-aarch64
            runner: ubuntu-24.04-arm
            arch: aarch64
            os: linux
            agentd_target: aarch64-unknown-linux-musl
            libkrunfw_file: libkrunfw.so.5.6.1
            libkrunfw_asset: libkrunfw-linux-aarch64.so
            napi_target: aarch64-unknown-linux-gnu
            node_file: microsandbox.linux-arm64-gnu.node
            npm_dir: linux-arm64-gnu
          - target: darwin-aarch64
            runner: macos-14
            arch: aarch64
            os: darwin
            agentd_target: ""
            libkrunfw_file: libkrunfw.5.dylib
            libkrunfw_asset: libkrunfw-darwin-aarch64.dylib
            napi_target: aarch64-apple-darwin
            node_file: microsandbox.darwin-arm64.node
            npm_dir: darwin-arm64

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

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

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

      # -- Linux build deps --
      - name: Install build deps (Linux)
        if: matrix.os == 'linux'
        run: sudo apt-get update && sudo apt-get install -y musl-tools libcap-ng-dev gcc make flex bison libelf-dev bc python3-pyelftools

      # -- agentd (Linux: native musl) --
      - name: Build agentd (musl)
        if: matrix.os == 'linux'
        run: |
          rustup target add ${{ matrix.agentd_target }}
          cargo build --release --manifest-path crates/agentd/Cargo.toml --target ${{ matrix.agentd_target }}
          mkdir -p build
          cp target/${{ matrix.agentd_target }}/release/agentd build/agentd

      # -- agentd (macOS: download prebuilt Linux artifact) --
      - name: Download agentd (macOS)
        if: matrix.os == 'darwin'
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          name: agentd-aarch64-linux-musl
          path: build/

      # -- libkrunfw (cached) --
      - 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-${{ matrix.target }}-${{ hashFiles('vendor/libkrunfw/**', '.github/actions/cache-libkrunfw-kernel/action.yml', '.github/workflows/release.yml') }}

      - name: Prepare kernel source (Linux)
        if: steps.cache-libkrunfw.outputs.cache-hit != 'true' && matrix.os == 'linux'
        uses: ./.github/actions/cache-libkrunfw-kernel

      - name: Build libkrunfw (Linux)
        if: steps.cache-libkrunfw.outputs.cache-hit != 'true' && matrix.os == 'linux'
        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

      - name: Download kernel.c (macOS)
        if: steps.cache-libkrunfw.outputs.cache-hit != 'true' && matrix.os == 'darwin'
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          name: kernel-c-aarch64
          path: vendor/libkrunfw/

      - name: Build libkrunfw (macOS)
        if: steps.cache-libkrunfw.outputs.cache-hit != 'true' && matrix.os == 'darwin'
        run: |
          cd vendor/libkrunfw
          cc -fPIC -DABI_VERSION=${{ env.LIBKRUNFW_ABI }} -shared -o libkrunfw.${{ env.LIBKRUNFW_ABI }}.dylib kernel.c
          cd ../..
          mkdir -p build
          cp vendor/libkrunfw/libkrunfw.${{ env.LIBKRUNFW_ABI }}.dylib build/
          cd build
          ln -sf libkrunfw.${{ env.LIBKRUNFW_ABI }}.dylib libkrunfw.dylib

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

      # -- msb-metrics --
      - name: Build msb-metrics
        run: |
          cargo build --release -p microsandbox-metrics-collector
          mkdir -p build
          cp target/release/msb-metrics build/msb-metrics

      # -- Go FFI cdylib --
      - name: Build microsandbox-go
        run: |
          cargo build --release -p microsandbox-go
          mkdir -p build
          if [ "${{ matrix.os }}" = "darwin" ]; then
            cp target/release/libmicrosandbox_go_ffi.dylib build/
          else
            cp target/release/libmicrosandbox_go_ffi.so build/
          fi

      # -- macOS codesign --
      - name: Codesign msb
        if: matrix.os == 'darwin'
        run: codesign --entitlements msb-entitlements.plist --force -s - build/msb

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

      - 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:native -- --target ${{ matrix.napi_target }}
          npm run build:ts

      - name: Prepare Node platform package
        working-directory: sdk/node-ts
        run: node scripts/prepare-platform-package.mjs ${{ matrix.npm_dir }}

      - name: Upload Node SDK artifacts
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: node-sdk-${{ matrix.npm_dir }}
          path: |
            sdk/node-ts/native/${{ matrix.node_file }}
            sdk/node-ts/native/index.cjs
            sdk/node-ts/native/index.d.ts
            sdk/node-ts/npm/${{ matrix.npm_dir }}/${{ matrix.node_file }}
            sdk/node-ts/npm/${{ matrix.npm_dir }}/bin/msb
            sdk/node-ts/npm/${{ matrix.npm_dir }}/lib/*

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

      - name: Stage runtime bundle (Python SDK)
        # Ship only the canonical libkrunfw filename — _runtime.py resolves
        # to that exact name. Maturin would dereference symlinks and bloat
        # the wheel by ~40MB on Linux / ~20MB on macOS for no benefit.
        run: |
          mkdir -p sdk/python/microsandbox/_bundled/bin
          mkdir -p sdk/python/microsandbox/_bundled/lib
          cp build/msb sdk/python/microsandbox/_bundled/bin/
          cp build/${{ matrix.libkrunfw_file }} sdk/python/microsandbox/_bundled/lib/

      - name: Build Python wheel
        uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1
        with:
          working-directory: sdk/python
          command: build
          args: --release --out dist
          manylinux: ${{ matrix.os == 'linux' && '2_28' || 'off' }}
          before-script-linux: dnf install -y libcap-ng-devel

      - name: Upload Python SDK wheel
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: python-sdk-${{ matrix.target }}
          path: sdk/python/dist/*.whl

      # -- Stage release artifacts --
      - name: Stage artifacts
        run: |
          mkdir -p artifacts

          # Standalone msb
          cp build/msb artifacts/msb-${{ matrix.target }}

          # Standalone msb-metrics
          cp build/msb-metrics artifacts/msb-metrics-${{ matrix.target }}

          # Standalone libkrunfw
          cp build/${{ matrix.libkrunfw_file }} artifacts/${{ matrix.libkrunfw_asset }}

          # Standalone agentd (Linux only — guest binary)
          if [ "${{ matrix.os }}" = "linux" ]; then
            cp build/agentd artifacts/agentd-${{ matrix.arch }}
          fi

          # Bundle: msb + libkrunfw
          if [ "${{ matrix.os }}" = "darwin" ]; then
            go_ffi_file=libmicrosandbox_go_ffi.dylib
          else
            go_ffi_file=libmicrosandbox_go_ffi.so
          fi
          tar -czf artifacts/microsandbox-${{ matrix.target }}.tar.gz \
            -C build msb ${{ matrix.libkrunfw_file }}

          # Standalone Go FFI cdylib named to match sdk/go/internal/bundle/
          # bundles/ so the go-sdk-tag job can drop it in directly.
          case "${{ matrix.target }}" in
            linux-x86_64)   bundle_name=libmicrosandbox_go_ffi-linux-amd64.so ;;
            linux-aarch64)  bundle_name=libmicrosandbox_go_ffi-linux-arm64.so ;;
            darwin-aarch64) bundle_name=libmicrosandbox_go_ffi-darwin-arm64.dylib ;;
          esac
          cp "build/$go_ffi_file" "artifacts/$bundle_name"

      - name: Upload artifacts
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: release-${{ matrix.target }}
          path: artifacts/

  # ---------------------------------------------------------------------------
  # Build Windows release bundles
  # ---------------------------------------------------------------------------
  build-windows:
    name: Build Windows (${{ matrix.target }})
    needs: [build-kernel, build-kernel-x86_64, build-agentd-aarch64, build-agentd-x86_64]
    runs-on: ${{ matrix.runner }}
    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
            napi_target: aarch64-pc-windows-msvc
            node_file: microsandbox.win32-arm64-msvc.node
            npm_dir: win32-arm64-msvc
            msb_asset: msb-windows-aarch64.exe
            msb_metrics_asset: msb-metrics-windows-aarch64.exe
            libkrunfw_asset: libkrunfw-windows-aarch64.dll
          - 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
            napi_target: x86_64-pc-windows-msvc
            node_file: microsandbox.win32-x64-msvc.node
            npm_dir: win32-x64-msvc
            msb_asset: msb-windows-x86_64.exe
            msb_metrics_asset: msb-metrics-windows-x86_64.exe
            libkrunfw_asset: libkrunfw-windows-x86_64.dll

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

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

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

      - 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
        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 build --release --no-default-features --features net,ssh -p microsandbox-cli --target ${{ matrix.rust_target }}
          New-Item -ItemType Directory -Force -Path build | Out-Null
          Copy-Item target\${{ matrix.rust_target }}\release\msb.exe build\msb.exe -Force

      - name: Build msb-metrics
        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 build --release -p microsandbox-metrics-collector --target ${{ matrix.rust_target }}
          New-Item -ItemType Directory -Force -Path build | Out-Null
          Copy-Item target\${{ matrix.rust_target }}\release\msb-metrics.exe build\msb-metrics.exe -Force

      # -- Go FFI cdylib --
      - name: Build microsandbox-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 build --release -p microsandbox-go --target ${{ matrix.rust_target }}
          New-Item -ItemType Directory -Force -Path build | Out-Null
          Copy-Item target\${{ matrix.rust_target }}\release\microsandbox_go_ffi.dll build\microsandbox_go_ffi.dll -Force

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

      - name: Build Node SDK
        shell: pwsh
        working-directory: sdk/node-ts
        run: |
          $ErrorActionPreference = "Stop"
          . "$env:GITHUB_WORKSPACE\vendor\libkrunfw\scripts\msvc-env.ps1"
          Set-MsvcEnvironment -Architecture ${{ matrix.vs_arch }} -HostArchitecture ${{ matrix.vs_host_arch }}
          node scripts/prune-platform-optional-deps.mjs
          npm install --package-lock=false
          npm run build:native -- --target ${{ matrix.napi_target }}
          npm run build:ts

      - name: Prepare Node platform package
        shell: pwsh
        working-directory: sdk/node-ts
        run: node scripts/prepare-platform-package.mjs ${{ matrix.npm_dir }}

      - name: Upload Node SDK artifacts
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: node-sdk-${{ matrix.npm_dir }}
          path: |
            sdk/node-ts/native/${{ matrix.node_file }}
            sdk/node-ts/native/index.cjs
            sdk/node-ts/native/index.d.ts
            sdk/node-ts/npm/${{ matrix.npm_dir }}/${{ matrix.node_file }}
            sdk/node-ts/npm/${{ matrix.npm_dir }}/bin/*
            sdk/node-ts/npm/${{ matrix.npm_dir }}/lib/*

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

      - name: Stage runtime bundle (Python SDK)
        shell: pwsh
        run: |
          $ErrorActionPreference = "Stop"
          New-Item -ItemType Directory -Force -Path sdk\python\microsandbox\_bundled\bin | Out-Null
          New-Item -ItemType Directory -Force -Path sdk\python\microsandbox\_bundled\lib | Out-Null
          Copy-Item build\msb.exe sdk\python\microsandbox\_bundled\bin\msb.exe -Force
          Copy-Item build\libkrunfw.dll sdk\python\microsandbox\_bundled\lib\libkrunfw.dll -Force

      - name: Build Python wheel
        uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1
        with:
          working-directory: sdk/python
          command: build
          args: --release --out dist --target ${{ matrix.rust_target }}

      - name: Upload Python SDK wheel
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: python-sdk-${{ matrix.target }}
          path: sdk/python/dist/*.whl

      - name: Stage artifacts
        shell: pwsh
        run: |
          $ErrorActionPreference = "Stop"
          New-Item -ItemType Directory -Force -Path artifacts | Out-Null
          Copy-Item build\msb.exe artifacts\${{ matrix.msb_asset }} -Force
          Copy-Item build\msb-metrics.exe artifacts\${{ matrix.msb_metrics_asset }} -Force
          Copy-Item build\libkrunfw.dll artifacts\${{ matrix.libkrunfw_asset }} -Force
          Compress-Archive -Path build\msb.exe, build\libkrunfw.dll -DestinationPath artifacts\microsandbox-${{ matrix.target }}.zip -Force
          tar.exe -czf artifacts\microsandbox-${{ matrix.target }}.tar.gz -C build msb.exe libkrunfw.dll
          # Go FFI cdylib: the Windows cargo artifact has no "lib" prefix; rename it
          # to the unified bundle asset name (lib prefix + Go arch) that the
          # go-sdk-tag job drops into sdk/go/internal/bundle/bundles/.
          $goArch = if ("${{ matrix.target }}" -eq "windows-x86_64") { "amd64" } else { "arm64" }
          Copy-Item build\microsandbox_go_ffi.dll "artifacts\libmicrosandbox_go_ffi-windows-$goArch.dll" -Force

      - name: Upload artifacts
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: release-${{ matrix.target }}
          path: artifacts/

  # ---------------------------------------------------------------------------
  # Assemble: collect all artifacts, generate checksums, create GitHub release
  # ---------------------------------------------------------------------------
  assemble:
    name: Assemble Release
    needs: [build, build-windows]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

      - name: Download all artifacts
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          path: release-artifacts
          pattern: release-*
          merge-multiple: true

      - name: Copy install script
        if: hashFiles('scripts/install.sh') != ''
        run: cp scripts/install.sh release-artifacts/install.sh

      - name: Copy Windows install script
        if: hashFiles('scripts/install.ps1') != ''
        run: cp scripts/install.ps1 release-artifacts/install.ps1

      - name: Generate checksums
        working-directory: release-artifacts
        run: sha256sum * > checksums.sha256

      - name: Create GitHub Release
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          # Auto-prerelease for rc / alpha / beta / dev tags so consumers
          # querying /releases/latest (e.g. install.microsandbox.dev) don't
          # accidentally promote them. Matches v0.5.0-rc-1, v1.2.3-rc1,
          # v1.0.0-alpha.4, etc.
          PRERELEASE=""
          if [[ "${{ github.ref_name }}" =~ -(rc|alpha|beta|dev) ]]; then
            PRERELEASE="--prerelease"
          fi
          gh release create "${{ github.ref_name }}" \
            --title "${{ github.ref_name }}" \
            --generate-notes \
            $PRERELEASE \
            release-artifacts/*

  # ---------------------------------------------------------------------------
  # Sync mintlify branch to the released commit
  #
  # mintlify is the docs deploy branch. it tracks the released commit but may
  # also carry docs that were merged into it directly. we merge the release
  # commit forward instead of hard-resetting, so those docs survive. mintlify
  # requires a merge queue that always creates a merge commit, so the sync
  # lands as a pr: a clean merge is enqueued automatically, a conflicting one
  # is left for the releaser to resolve by hand instead of losing work
  # silently. the queue keeps the history shared with main intact; squashing
  # these prs is what used to turn every following sync into a wall of false
  # conflicts.
  # ---------------------------------------------------------------------------
  sync-mintlify:
    name: Sync mintlify branch
    needs: assemble
    runs-on: ubuntu-latest
    permissions:
      contents: write
      pull-requests: write
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          ref: mintlify
          fetch-depth: 0

      - name: Configure git identity
        run: |
          git config user.name "github-actions[bot]"
          git config user.email "github-actions[bot]@users.noreply.github.com"

      - name: Check whether the release commit merges cleanly
        id: merge
        run: |
          if git merge-base --is-ancestor ${{ github.sha }} HEAD; then
            echo "result=uptodate" >> "$GITHUB_OUTPUT"
          elif git merge --no-commit --no-ff ${{ github.sha }}; then
            git merge --abort
            echo "result=clean" >> "$GITHUB_OUTPUT"
          else
            git merge --abort
            echo "result=conflict" >> "$GITHUB_OUTPUT"
          fi

      - name: Open sync PR
        if: steps.merge.outputs.result != 'uptodate'
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          branch="mintlify-sync-${{ github.ref_name }}"
          git push --force origin ${{ github.sha }}:refs/heads/"$branch"
          if [ "${{ steps.merge.outputs.result }}" = "clean" ]; then
            body="Syncs mintlify to ${{ github.ref_name }}. The release commit merges cleanly, so this PR was enqueued automatically; the merge queue lands it as a merge commit."
          else
            body="Releasing ${{ github.ref_name }} could not merge into mintlify cleanly. mintlify carries docs that conflict with this release. Resolve by merging mintlify into this branch and pushing, then add the PR to the merge queue (merge when ready). The queue lands it as a merge commit, which keeps the history shared with main intact. Do not bypass the queue with a squash: that erases the shared history and turns the next sync into a wall of false conflicts."
          fi
          gh pr create \
            --base mintlify \
            --head "$branch" \
            --title "sync mintlify to ${{ github.ref_name }}" \
            --body "$body"

      - name: Enqueue clean sync PR
        if: steps.merge.outputs.result == 'clean'
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: gh pr merge --auto --merge "mintlify-sync-${{ github.ref_name }}"

  # ---------------------------------------------------------------------------
  # Update Homebrew formula in the tap repo
  # ---------------------------------------------------------------------------
  update-homebrew:
    name: Update Homebrew Formula
    needs: assemble
    runs-on: ubuntu-latest
    steps:
      - name: Dispatch to homebrew tap
        env:
          GH_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_PAT }}
        run: |
          VERSION="${GITHUB_REF_NAME#v}"
          gh api repos/superradcompany/homebrew-tap/dispatches \
            -f event_type=update-formula \
            -f "client_payload[version]=$VERSION"

  # ---------------------------------------------------------------------------
  # Submit the new version to the Windows Package Manager (winget-pkgs)
  #
  # winget-releaser runs Komac, which copies the previous version's manifests
  # from microsoft/winget-pkgs, bumps the version, and rewrites the installer
  # URLs and SHA256 hashes from this release's Windows .zip assets, then opens a
  # PR against microsoft/winget-pkgs from a fork.
  #
  # One-time preconditions:
  #   - At least one version of SuperRadCompany.Microsandbox must already exist
  #     in winget-pkgs. The first release is submitted by hand from the staged
  #     manifests under packaging/winget/ (see packaging/winget/README.md);
  #     Komac errors out if the package is not yet present.
  #   - WINGET_TOKEN: a classic PAT with `public_repo` scope whose owner has a
  #     fork of microsoft/winget-pkgs. Set the WINGET_FORK_USER repository
  #     variable if that fork lives under a different account than this repo's
  #     owner.
  #   - WINGET_RELEASE_ENABLED: set this repository variable to `true` only
  #     after the first package version is accepted into winget-pkgs.
  #
  # The job is skipped unless publishing is enabled, and the submit step is a
  # no-op when WINGET_TOKEN is unset. This keeps releases from failing before
  # the initial package is accepted or for forks that have not configured
  # winget publishing. Prerelease tags (rc/alpha/beta/dev) are skipped to match
  # the assemble job's --prerelease detection.
  # ---------------------------------------------------------------------------
  update-winget:
    name: Update winget package
    needs: assemble
    if: >-
      vars.WINGET_RELEASE_ENABLED == 'true'
      && !contains(github.ref_name, '-rc')
      && !contains(github.ref_name, '-alpha')
      && !contains(github.ref_name, '-beta')
      && !contains(github.ref_name, '-dev')
    runs-on: ubuntu-latest
    env:
      WINGET_TOKEN: ${{ secrets.WINGET_TOKEN }}
    steps:
      - name: Submit to winget-pkgs
        if: env.WINGET_TOKEN != ''
        uses: vedantmgoyal9/winget-releaser@4ffc7888bffd451b357355dc214d43bb9f23917e # v2
        with:
          identifier: SuperRadCompany.Microsandbox
          installers-regex: 'microsandbox-windows-.+\.zip$'
          release-tag: ${{ github.ref_name }}
          release-notes-url: https://github.com/superradcompany/microsandbox/releases/tag/${{ github.ref_name }}
          fork-user: ${{ vars.WINGET_FORK_USER || github.repository_owner }}
          token: ${{ secrets.WINGET_TOKEN }}

  # ---------------------------------------------------------------------------
  # Publish Node SDK to npm
  # ---------------------------------------------------------------------------
  npm-publish:
    name: Publish npm packages
    needs: [build, build-windows]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

      - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
        with:
          node-version: 22
          registry-url: https://registry.npmjs.org

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

      - name: Place binaries and update generated files
        run: |
          # Copy prepared platform package payloads.
          for dir in darwin-arm64 linux-x64-gnu linux-arm64-gnu win32-x64-msvc win32-arm64-msvc; do
            mkdir -p "sdk/node-ts/npm/$dir/bin" "sdk/node-ts/npm/$dir/lib"
            cp "node-artifacts/node-sdk-$dir/npm/$dir"/microsandbox.*.node "sdk/node-ts/npm/$dir/"
            cp "node-artifacts/node-sdk-$dir/npm/$dir/bin/"* "sdk/node-ts/npm/$dir/bin/"
            cp "node-artifacts/node-sdk-$dir/npm/$dir/lib/"* "sdk/node-ts/npm/$dir/lib/"
            # GitHub Actions artifacts strip Unix exec bits; restore before publish
            # so the published tarball carries 0755 on the binary.
            if [ -f "sdk/node-ts/npm/$dir/bin/msb" ]; then
              chmod +x "sdk/node-ts/npm/$dir/bin/msb"
            fi
          done

          # Copy napi-generated bindings into the root package's native/ dir
          # (use darwin-arm64; all platforms emit identical JS and types).
          # napi build emits index.d.ts; rename to index.d.cts so nodenext
          # resolution finds it next to the .cjs binding.
          cp node-artifacts/node-sdk-darwin-arm64/native/index.cjs sdk/node-ts/native/index.cjs
          cp node-artifacts/node-sdk-darwin-arm64/native/index.d.ts sdk/node-ts/native/index.d.cts

      - name: Build TypeScript output (root package dist/)
        working-directory: sdk/node-ts
        run: |
          npm install --omit=optional --package-lock=false
          npm run build:ts

      - name: Build and publish agent client package
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
        working-directory: packages/agent-client/typescript
        run: |
          npm ci
          npm run build
          npm publish --access public

      - name: Build and publish microsandbox types package
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
        working-directory: packages/microsandbox-types/typescript
        run: |
          npm ci
          npm run build
          npm publish --access public

      - name: Publish platform packages
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
        run: |
          for dir in darwin-arm64 linux-x64-gnu linux-arm64-gnu win32-x64-msvc win32-arm64-msvc; do
            echo "Publishing @superradcompany/microsandbox-$dir..."
            cd sdk/node-ts/npm/$dir
            npm publish --access public
            cd ../../../..
          done

      - name: Wait for npm to index platform packages
        run: sleep 30

      - name: Verify root package platform dependencies
        working-directory: sdk/node-ts
        run: |
          node - <<'NODE'
          const fs = require("node:fs");

          const pkg = JSON.parse(fs.readFileSync("package.json", "utf8"));
          const optionalDependencies = pkg.optionalDependencies ?? {};
          const required = [
            "@superradcompany/microsandbox-darwin-arm64",
            "@superradcompany/microsandbox-linux-arm64-gnu",
            "@superradcompany/microsandbox-linux-x64-gnu",
            "@superradcompany/microsandbox-win32-arm64-msvc",
            "@superradcompany/microsandbox-win32-x64-msvc",
          ];

          const missing = required.filter((name) => optionalDependencies[name] !== pkg.version);

          if (missing.length > 0) {
            console.error(`Missing or mismatched platform optional dependencies: ${missing.join(", ")}`);
            process.exit(1);
          }
          NODE

      - name: Publish root package
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
        working-directory: sdk/node-ts
        run: npm publish --access public

  # ---------------------------------------------------------------------------
  # Refresh sdk/node-ts/package-lock.json against the just-published platform
  # packages, so main always has lockfile integrity hashes that satisfy
  # `npm ci`. Opens a PR that the maintainer merges manually.
  # ---------------------------------------------------------------------------
  refresh-lockfile:
    name: Refresh npm lockfile
    needs: npm-publish
    runs-on: ubuntu-latest
    permissions:
      contents: write
      pull-requests: write
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          ref: main

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

      - name: Wait for npm to index platform packages
        run: sleep 60

      - name: Regenerate lockfile
        working-directory: sdk/node-ts
        run: npm install --package-lock-only --ignore-scripts

      - uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
        with:
          title: "chore: refresh npm lockfile after ${{ github.ref_name }}"
          body: |
            Regenerates `sdk/node-ts/package-lock.json` against the platform packages published for ${{ github.ref_name }}, so `npm ci` keeps working on main.
          branch: refresh-lockfile-${{ github.ref_name }}
          commit-message: "chore: refresh npm lockfile after ${{ github.ref_name }}"
          base: main
          add-paths: sdk/node-ts/package-lock.json
          delete-branch: true

  # ---------------------------------------------------------------------------
  # Publish MCP server to npm
  # ---------------------------------------------------------------------------
  mcp-publish:
    name: Publish MCP server
    needs: npm-publish
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          submodules: true

      - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
        with:
          node-version: 22
          registry-url: https://registry.npmjs.org

      - name: Build and publish MCP server
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
        working-directory: mcp
        run: |
          rm -f package-lock.json
          npm install
          npm run build
          npm publish --access public

  # ---------------------------------------------------------------------------
  # Publish Rust crates to crates.io
  # ---------------------------------------------------------------------------
  crates-publish:
    name: Publish crates
    needs: build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          submodules: true

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

      - name: Publish to crates.io
        env:
          CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
        run: |
          for crate in \
            microsandbox-utils \
            microsandbox-types \
            microsandbox-protocol \
            microsandbox-agent-client \
            microsandbox-agentd \
            microsandbox-db \
            microsandbox-migration \
            microsandbox-image \
            microsandbox-filesystem \
            microsandbox-network \
            microsandbox-metrics \
            microsandbox-metrics-collector \
            microsandbox-runtime \
            microsandbox \
            microsandbox-cli; do
            echo "Publishing $crate..."
            cargo publish -p "$crate" --no-verify
            sleep 60
          done

  # ---------------------------------------------------------------------------
  # Publish Python SDK to PyPI
  # ---------------------------------------------------------------------------
  pypi-publish:
    name: Publish Python SDK
    needs: [build, build-windows]
    runs-on: ubuntu-latest
    permissions:
      id-token: write
    environment:
      name: pypi
      url: https://pypi.org/p/microsandbox
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

      - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0

      - name: Download Python SDK wheels
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          path: dist
          pattern: python-sdk-*
          merge-multiple: true

      - name: List wheels
        run: ls -lh dist/

      - name: Publish to PyPI
        run: uv publish dist/*.whl

  # ---------------------------------------------------------------------------
  # Publish Go SDK by populating embedded bundles and creating the
  # subdirectory module tag.
  #
  # Go modules in subdirectories use prefixed tags (`sdk/go/vX.Y.Z`) so
  # `go get .../sdk/go@vX.Y.Z` resolves. proxy.golang.org fetches the
  # tagged commit's source tree, so the per-platform FFI .so/.dylib must
  # be committed under sdk/go/internal/bundle/bundles/ on that commit.
  #
  # main keeps 0-byte sentinel bundles; this job lays a one-off release
  # commit on top of GITHUB_SHA with the real binaries, tags it, and
  # pushes only the tag. The release commit is reachable solely via the
  # tag, never via a branch (wasmtime-go's distribution pattern).
  # ---------------------------------------------------------------------------
  go-sdk-tag:
    name: Tag Go SDK module
    needs: assemble
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          fetch-depth: 0

      - name: Download per-platform release artifacts
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          path: release-dl
          pattern: release-*
          merge-multiple: true

      - name: Populate embedded bundles and tag
        run: |
          set -euo pipefail
          ROOT_TAG="${GITHUB_REF_NAME}"          # e.g. v0.4.6
          GO_TAG="sdk/go/${ROOT_TAG}"
          if git rev-parse "refs/tags/${GO_TAG}" >/dev/null 2>&1; then
            echo "tag ${GO_TAG} already exists — skipping"
            exit 0
          fi

          # Drop the platform binaries into the embed dir, replacing the
          # 0-byte sentinels committed on main.
          install -m644 release-dl/libmicrosandbox_go_ffi-linux-amd64.so \
            sdk/go/internal/bundle/bundles/libmicrosandbox_go_ffi-linux-amd64.so
          install -m644 release-dl/libmicrosandbox_go_ffi-linux-arm64.so \
            sdk/go/internal/bundle/bundles/libmicrosandbox_go_ffi-linux-arm64.so
          install -m644 release-dl/libmicrosandbox_go_ffi-darwin-arm64.dylib \
            sdk/go/internal/bundle/bundles/libmicrosandbox_go_ffi-darwin-arm64.dylib
          install -m644 release-dl/libmicrosandbox_go_ffi-windows-amd64.dll \
            sdk/go/internal/bundle/bundles/libmicrosandbox_go_ffi-windows-amd64.dll
          install -m644 release-dl/libmicrosandbox_go_ffi-windows-arm64.dll \
            sdk/go/internal/bundle/bundles/libmicrosandbox_go_ffi-windows-arm64.dll

          git config user.name  "github-actions[bot]"
          git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
          git add sdk/go/internal/bundle/bundles/
          git commit -m "release: Go SDK ${ROOT_TAG} (populate embedded FFI bundles)"
          git tag -a "${GO_TAG}" -m "Go SDK ${ROOT_TAG}" HEAD
          git push origin "${GO_TAG}"

      - name: Warm Go module proxy
        # Force proxy.golang.org and sum.golang.org to ingest the new
        # tag now instead of waiting for their scheduled discovery, which
        # can take 10-60 minutes and leaves `go get @<new-version>`
        # returning 404 for users on default GOPROXY in the meantime.
        run: |
          ROOT_TAG="${GITHUB_REF_NAME}"
          MODULE="github.com/superradcompany/microsandbox/sdk/go"
          curl -sSf "https://proxy.golang.org/${MODULE}/@v/${ROOT_TAG}.info" >/dev/null || true
          curl -sSf "https://proxy.golang.org/${MODULE}/@v/${ROOT_TAG}.mod"  >/dev/null || true
          curl -sSf "https://sum.golang.org/lookup/${MODULE}@${ROOT_TAG}"    >/dev/null || true

  # ---------------------------------------------------------------------------
  # Publish Docker image to GHCR
  # ---------------------------------------------------------------------------
  # Build per-arch Docker images and push digests
  # ---------------------------------------------------------------------------
  docker-build:
    name: Docker (${{ matrix.arch }})
    needs: build
    runs-on: ${{ matrix.runner }}
    strategy:
      matrix:
        include:
          - arch: amd64
            runner: ubuntu-latest
            artifact: release-linux-x86_64
            msb_asset: msb-linux-x86_64
            libkrunfw_asset: libkrunfw-linux-x86_64.so
          - arch: arm64
            runner: ubuntu-24.04-arm
            artifact: release-linux-aarch64
            msb_asset: msb-linux-aarch64
            libkrunfw_asset: libkrunfw-linux-aarch64.so
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

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

      - name: Stage binaries
        run: |
          mkdir -p packaging/docker/build/${{ matrix.arch }}
          cp release-artifacts/${{ matrix.msb_asset }} packaging/docker/build/${{ matrix.arch }}/msb
          cp release-artifacts/${{ matrix.libkrunfw_asset }} packaging/docker/build/${{ matrix.arch }}/libkrunfw.so.${{ env.LIBKRUNFW_VERSION }}
          chmod +x packaging/docker/build/${{ matrix.arch }}/msb

      - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0

      - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
        id: push
        with:
          context: packaging/docker
          platforms: linux/${{ matrix.arch }}
          cache-from: type=gha,scope=${{ matrix.arch }}
          cache-to: type=gha,mode=max,scope=${{ matrix.arch }}
          outputs: type=image,name=ghcr.io/superradcompany/microsandbox,push-by-digest=true,name-canonical=true,push=true

      - name: Export digest
        run: |
          mkdir -p /tmp/digests
          echo "${{ steps.push.outputs.digest }}" > /tmp/digests/${{ matrix.arch }}.txt

      - name: Upload digest
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: docker-digest-${{ matrix.arch }}
          path: /tmp/digests/${{ matrix.arch }}.txt

  # ---------------------------------------------------------------------------
  # Combine per-arch images into a multi-arch manifest
  # ---------------------------------------------------------------------------
  docker-manifest:
    name: Docker manifest
    needs: docker-build
    runs-on: ubuntu-latest
    steps:
      - name: Download digests
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          path: digests
          pattern: docker-digest-*
          merge-multiple: true

      - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
        id: meta
        with:
          images: ghcr.io/superradcompany/microsandbox
          tags: |
            type=semver,pattern={{version}}
            type=semver,pattern={{major}}.{{minor}}
            type=raw,value=latest

      - name: Create multi-arch manifest
        run: |
          AMD64_DIGEST=$(cat digests/amd64.txt)
          ARM64_DIGEST=$(cat digests/arm64.txt)
          IMAGE=ghcr.io/superradcompany/microsandbox

          for TAG in $(echo "${{ steps.meta.outputs.tags }}"); do
            docker buildx imagetools create -t "$TAG" \
              "${IMAGE}@${AMD64_DIGEST}" \
              "${IMAGE}@${ARM64_DIGEST}"
          done
