name: Release

on:
  push:
    tags: ["v*"]
  pull_request:
    branches-ignore:
      - mintlify
    paths:
      - .github/workflows/release.yml
      - .github/workflows/release-linux.yml
      - .github/workflows/release-macos.yml
      - .github/workflows/release-windows.yml
      - .github/actions/cache-libkrunfw-kernel/action.yml
      - scripts/ci/publish-crates.py
      - scripts/ci/test_publish_crates.py
      - scripts/ci/validate-release-artifacts.py
      - scripts/ci/test_validate_release_artifacts.py
      - scripts/smoke/cli/runtime-discovery.py
      - sdk/node-ts/scripts/cloud-release-smoke.mts
      - sdk/node-ts/scripts/cloud-release-smoke.test.mts
      - scripts/ci/validate_linux_glibc.py
      - scripts/ci/test_validate_linux_glibc.py
      - scripts/ci/wait-for-npm-packages.mjs
      - scripts/ci/publish-npm-package.mjs
  # Manual runs exercise the full release build and validation graph. Every
  # irreversible job below is independently guarded to tag pushes only.
  workflow_dispatch:

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"
  CARGO_INCREMENTAL: "0"
  LIBKRUNFW_VERSION: "5.6.1"
  LIBKRUNFW_ABI: "5"

permissions:
  contents: read

jobs:
  # Fetch and verify the large kernel archive once, then fan it out to both
  # firmware architectures over GitHub's artifact network.
  kernel-sources:
    name: Kernel sources
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

      - name: Checkout libkrunfw sources
        run: git submodule update --init --depth 1 vendor/libkrunfw

      - name: Prepare kernel source
        uses: ./.github/actions/cache-libkrunfw-kernel

      - name: Upload kernel source archive
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: kernel-source
          path: vendor/libkrunfw/tarballs/*.tar.gz
          compression-level: 0

  # Build each firmware bundle once. Platform workflows consume the generated
  # kernel.c; Linux relinks it inside its baseline container.
  firmware-aarch64:
    name: Firmware (aarch64)
    needs: kernel-sources
    runs-on: ubuntu-24.04-arm
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

      - name: Checkout libkrunfw sources
        run: git submodule update --init --depth 1 vendor/libkrunfw

      - name: Cache firmware
        id: cache-firmware
        uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
        with:
          path: |
            vendor/libkrunfw/kernel.c
            vendor/libkrunfw/libkrunfw.so.${{ env.LIBKRUNFW_VERSION }}
          key: release-firmware-aarch64-${{ hashFiles('vendor/libkrunfw/**') }}

      - name: Install firmware build dependencies
        if: steps.cache-firmware.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: Download kernel source archive
        if: steps.cache-firmware.outputs.cache-hit != 'true'
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          name: kernel-source
          path: vendor/libkrunfw/tarballs

      - name: Build firmware
        if: steps.cache-firmware.outputs.cache-hit != 'true'
        working-directory: vendor/libkrunfw
        run: make -j$(nproc)

      - name: Upload firmware
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: firmware-aarch64
          path: |
            vendor/libkrunfw/kernel.c
            vendor/libkrunfw/libkrunfw.so.${{ env.LIBKRUNFW_VERSION }}
          compression-level: 0

  firmware-x86_64:
    name: Firmware (x86_64)
    needs: kernel-sources
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

      - name: Checkout libkrunfw sources
        run: git submodule update --init --depth 1 vendor/libkrunfw

      - name: Cache firmware
        id: cache-firmware
        uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
        with:
          path: |
            vendor/libkrunfw/kernel.c
            vendor/libkrunfw/libkrunfw.so.${{ env.LIBKRUNFW_VERSION }}
          key: release-firmware-x86_64-${{ hashFiles('vendor/libkrunfw/**') }}

      - name: Install firmware build dependencies
        if: steps.cache-firmware.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: Download kernel source archive
        if: steps.cache-firmware.outputs.cache-hit != 'true'
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          name: kernel-source
          path: vendor/libkrunfw/tarballs

      - name: Build firmware
        if: steps.cache-firmware.outputs.cache-hit != 'true'
        working-directory: vendor/libkrunfw
        run: make -j$(nproc)

      - name: Upload firmware
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: firmware-x86_64
          path: |
            vendor/libkrunfw/kernel.c
            vendor/libkrunfw/libkrunfw.so.${{ env.LIBKRUNFW_VERSION }}
          compression-level: 0

  # The guest daemon is independent from each host build, so compile it once
  # per guest architecture and fan it out to every matching host platform.
  agentd-aarch64:
    name: Agentd (aarch64-linux-musl)
    runs-on: ubuntu-24.04-arm
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

      - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
        with:
          targets: aarch64-unknown-linux-musl

      - uses: Swatinem/rust-cache@f0d9c3887740aee45f6153b24b3a6b815192ec16 # v2
        with:
          cache-bin: false
          cache-targets: true
          shared-key: release-agentd-aarch64

      - name: Install musl toolchain
        run: sudo apt-get update && sudo apt-get install -y musl-tools

      - name: Build agentd
        run: cargo build --release --manifest-path crates/agentd/Cargo.toml --target aarch64-unknown-linux-musl

      - name: Upload agentd
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: agentd-aarch64-linux-musl
          path: target/aarch64-unknown-linux-musl/release/agentd
          compression-level: 0

  agentd-x86_64:
    name: Agentd (x86_64-linux-musl)
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

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

      - uses: Swatinem/rust-cache@f0d9c3887740aee45f6153b24b3a6b815192ec16 # v2
        with:
          cache-bin: false
          cache-targets: true
          shared-key: release-agentd-x86_64

      - name: Install musl toolchain
        run: sudo apt-get update && sudo apt-get install -y musl-tools

      - name: Build agentd
        run: cargo build --release --manifest-path crates/agentd/Cargo.toml --target x86_64-unknown-linux-musl

      - name: Upload agentd
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: agentd-x86_64-linux-musl
          path: target/x86_64-unknown-linux-musl/release/agentd
          compression-level: 0

  linux-x86_64:
    name: Linux x86_64
    needs: [firmware-x86_64, agentd-x86_64]
    uses: ./.github/workflows/release-linux.yml
    with:
      target: linux-x86_64
      runner: ubuntu-latest
      manylinux_image: quay.io/pypa/manylinux_2_28_x86_64@sha256:0c87ccb5996dab6c3b7612ee4fda7b80c4ab3c44a86c2541e4a872afdf4f131b
      arch: x86_64
      agentd_artifact: agentd-x86_64-linux-musl
      firmware_artifact: firmware-x86_64
      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
      go_ffi_file: libmicrosandbox_go_ffi.so
      go_bundle_name: libmicrosandbox_go_ffi-linux-amd64.so

  linux-aarch64:
    name: Linux aarch64
    needs: [firmware-aarch64, agentd-aarch64]
    uses: ./.github/workflows/release-linux.yml
    with:
      target: linux-aarch64
      runner: ubuntu-24.04-arm
      manylinux_image: quay.io/pypa/manylinux_2_28_aarch64@sha256:561427136aabf3787bffb294b3515748241e0962d1527ae28bea1e076bfb9d99
      arch: aarch64
      agentd_artifact: agentd-aarch64-linux-musl
      firmware_artifact: firmware-aarch64
      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
      go_ffi_file: libmicrosandbox_go_ffi.so
      go_bundle_name: libmicrosandbox_go_ffi-linux-arm64.so

  darwin-aarch64:
    name: macOS aarch64
    needs: [firmware-aarch64, agentd-aarch64]
    uses: ./.github/workflows/release-macos.yml
    with:
      target: darwin-aarch64
      runner: macos-14
      os: darwin
      arch: aarch64
      agentd_artifact: agentd-aarch64-linux-musl
      firmware_artifact: firmware-aarch64
      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
      go_ffi_file: libmicrosandbox_go_ffi.dylib
      go_bundle_name: libmicrosandbox_go_ffi-darwin-arm64.dylib

  windows-x86_64:
    name: Windows x86_64
    needs: [firmware-x86_64, agentd-x86_64]
    uses: ./.github/workflows/release-windows.yml
    with:
      target: windows-x86_64
      runner: windows-latest
      rust_target: x86_64-pc-windows-msvc
      firmware_artifact: firmware-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
      metrics_asset: msb-metrics-windows-x86_64.exe
      libkrunfw_asset: libkrunfw-windows-x86_64.dll
      go_bundle_name: libmicrosandbox_go_ffi-windows-amd64.dll

  windows-aarch64:
    name: Windows aarch64
    needs: [firmware-aarch64, agentd-aarch64]
    uses: ./.github/workflows/release-windows.yml
    with:
      target: windows-aarch64
      runner: windows-11-arm
      rust_target: aarch64-pc-windows-msvc
      firmware_artifact: firmware-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
      metrics_asset: msb-metrics-windows-aarch64.exe
      libkrunfw_asset: libkrunfw-windows-aarch64.dll
      go_bundle_name: libmicrosandbox_go_ffi-windows-arm64.dll

  # ---------------------------------------------------------------------------
  # Legacy tag-release graph. Keep this disabled as an in-file rollback
  # reference until the optimized graph has proven itself on live tags.
  # ---------------------------------------------------------------------------
  # Build kernel.c on Linux for macOS libkrunfw linking
  build-kernel:
    name: Build kernel.c (aarch64)
    if: ${{ false }}
    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)
    if: ${{ false }}
    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)
    if: ${{ false }}
    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@f0d9c3887740aee45f6153b24b3a6b815192ec16 # 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)
    if: ${{ false }}
    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@f0d9c3887740aee45f6153b24b3a6b815192ec16 # 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: ${{ false }}
    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@f0d9c3887740aee45f6153b24b3a6b815192ec16 # 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 embed-binaries,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 ci
          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@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.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]
    if: ${{ false }}
    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@f0d9c3887740aee45f6153b24b3a6b815192ec16 # 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 embed-binaries,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 ci
          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@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.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/

  # Completeness barrier: no publisher may run until every platform and SDK
  # payload has been built and the exact release manifest has been verified.
  release-ready:
    name: Validate complete release
    if: always()
    needs:
      - linux-x86_64
      - linux-aarch64
      - darwin-aarch64
      - windows-x86_64
      - windows-aarch64
    runs-on: ubuntu-latest
    steps:
      - name: Assert every platform build succeeded
        run: |
          test "${{ needs.linux-x86_64.result }}" = success
          test "${{ needs.linux-aarch64.result }}" = success
          test "${{ needs.darwin-aarch64.result }}" = success
          test "${{ needs.windows-x86_64.result }}" = success
          test "${{ needs.windows-aarch64.result }}" = success

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

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

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

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

      - name: Validate artifact manifest
        run: |
          python3 -m unittest scripts.ci.test_validate_release_artifacts
          python3 scripts/ci/validate-release-artifacts.py \
            --release-dir release-artifacts \
            --node-dir node-artifacts \
            --python-dir python-artifacts \
            --manifest release-validation/manifest.json

      - name: Validate Linux glibc baseline
        run: |
          python3 -m unittest scripts.ci.test_validate_linux_glibc
          python3 scripts/ci/validate_linux_glibc.py \
            --max-version 2.28 \
            --installer scripts/install.sh \
            release-artifacts node-artifacts python-artifacts

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

      - name: Validate crates.io publication graph and package manifests
        run: |
          python3 -m unittest scripts.ci.test_publish_crates
          python3 scripts/ci/publish-crates.py --validate-only

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

      # Exercise the same packaging path as npm-publish, including the candidate
      # native binding. No already-published microsandbox package is installed.
      - name: Build and check TypeScript smoke test
        working-directory: sdk/node-ts
        run: |
          node --test ../../scripts/ci/test-npm-release.mjs
          node ../../scripts/ci/build-unpublished-node-sdk.mjs
          npx --no-install tsc --noEmit --strict --skipLibCheck --target ES2023 --module nodenext --allowImportingTsExtensions scripts/cloud-release-smoke.mts scripts/cloud-release-smoke.test.mts
          node --experimental-strip-types --test scripts/cloud-release-smoke.test.mts

      # Never expose production credentials to PR code. Manual dry-runs run the
      # live gate only on main; every publishing tag must pass it.
      - name: Install candidate SDK for production smoke test
        id: smoke-install
        if: >-
          (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')) ||
          (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main')
        run: |
          platform=sdk/node-ts/npm/linux-x64-gnu
          artifact=node-artifacts/node-sdk-linux-x64-gnu
          mkdir -p "$platform/bin" "$platform/lib"
          cp "$artifact/npm/linux-x64-gnu/"*.node "$platform/"
          cp "$artifact/npm/linux-x64-gnu/bin/"* "$platform/bin/"
          cp "$artifact/npm/linux-x64-gnu/lib/"* "$platform/lib/"
          chmod +x "$platform/bin/msb"
          cp "$artifact/native/index.cjs" sdk/node-ts/native/index.cjs
          cp "$artifact/native/index.d.ts" sdk/node-ts/native/index.d.cts
          pack_dir="$RUNNER_TEMP/cloud-smoke-packs"
          consumer_dir="$RUNNER_TEMP/cloud-smoke-consumer"
          mkdir -p "$pack_dir" "$consumer_dir"
          # Keep the smoke consumer entirely on candidate packages, including
          # the SDK's runtime types dependency before this version is published.
          types_tarball=$(cd packages/microsandbox-types/typescript && npm pack --silent --pack-destination "$pack_dir")
          main_tarball=$(cd sdk/node-ts && npm pack --silent --pack-destination "$pack_dir")
          platform_tarball=$(cd "$platform" && npm pack --silent --pack-destination "$pack_dir")
          cp sdk/node-ts/scripts/cloud-release-smoke.mts "$consumer_dir/"
          cd "$consumer_dir"
          npm init --yes >/dev/null
          npm install --ignore-scripts --omit=optional "$pack_dir/$types_tarball" "$pack_dir/$main_tarball" "$pack_dir/$platform_tarball"
          node --input-type=module -e 'await import("microsandbox")'

      - name: Smoke test candidate SDK against production
        id: cloud-smoke
        if: steps.smoke-install.outcome == 'success'
        timeout-minutes: 6
        env:
          MSB_API_KEY: ${{ secrets.MSB_API_KEY }}
          MSB_BACKEND: cloud
          MSB_API_URL: https://api.microsandbox.dev
          MSB_HOME: ${{ runner.temp }}/cloud-smoke-home
          MSB_SMOKE_SANDBOX_NAME: release-smoke-${{ github.run_id }}-${{ github.run_attempt }}
        run: node --experimental-strip-types "$RUNNER_TEMP/cloud-smoke-consumer/cloud-release-smoke.mts" run

      - name: Clean up production smoke sandbox
        if: ${{ always() && steps.cloud-smoke.outcome != 'skipped' && steps.smoke-install.outcome == 'success' }}
        timeout-minutes: 4
        env:
          MSB_API_KEY: ${{ secrets.MSB_API_KEY }}
          MSB_BACKEND: cloud
          MSB_API_URL: https://api.microsandbox.dev
          MSB_HOME: ${{ runner.temp }}/cloud-smoke-home
          MSB_SMOKE_SANDBOX_NAME: release-smoke-${{ github.run_id }}-${{ github.run_attempt }}
        run: node --experimental-strip-types "$RUNNER_TEMP/cloud-smoke-consumer/cloud-release-smoke.mts" cleanup

      - name: Upload validation manifest
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: release-validation
          path: release-validation/manifest.json

  # ---------------------------------------------------------------------------
  # Assemble: collect all artifacts, generate checksums, create GitHub release
  # ---------------------------------------------------------------------------
  assemble:
    name: Assemble Release
    needs: release-ready
    if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
    runs-on: ubuntu-latest
    permissions:
      contents: write
      issues: write
      pull-requests: write
    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: Prepare categorized release notes
        uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
        with:
          script: |
            const { prepareReleaseNotes } = require('./scripts/ci/release-notes.cjs');
            const body = await prepareReleaseNotes({
              github, repo: context.repo, tag: process.env.GITHUB_REF_NAME,
              serverUrl: context.serverUrl,
            });
            require('node:fs').writeFileSync(`${process.env.RUNNER_TEMP}/release-notes.md`, body);

      - 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 }}" \
            --notes-file "$RUNNER_TEMP/release-notes.md" \
            $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
    if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
    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 switch --detach "${{ github.sha }}"
          git commit --allow-empty -m "chore(docs): sync mintlify to ${{ github.ref_name }}"
          git push --force origin HEAD: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
    if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
    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: >-
      github.event_name == 'push'
      && startsWith(github.ref, 'refs/tags/v')
      && 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: release-ready
    if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write
    env:
      # Every publish in this job uses the existing token plus GitHub OIDC
      # provenance. MCP is published separately from its submodule repository.
      NPM_CONFIG_PROVENANCE: "true"
    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).
          # Keep the declaration path aligned with package.json exports/files.
          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.ts

      - name: Build shared TypeScript packages in dependency order
        working-directory: packages
        run: |
          npm ci
          npm run build

      - name: Publish shared types and protocol engine
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
        working-directory: packages
        run: |
          node ../scripts/ci/publish-npm-package.mjs microsandbox-types/typescript
          node ../scripts/ci/publish-npm-package.mjs protocol-client/typescript

      - name: Wait for shared dependencies to be indexed
        run: |
          VERSION="${GITHUB_REF_NAME#v}"
          node scripts/ci/wait-for-npm-packages.mjs "$VERSION" \
            @microsandbox/types @microsandbox/protocol-client

      - name: Publish agent and control clients
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
        working-directory: packages
        run: |
          node ../scripts/ci/publish-npm-package.mjs agent-client/typescript
          node ../scripts/ci/publish-npm-package.mjs control-client/typescript

      - name: Wait for shared clients to be indexed
        run: |
          VERSION="${GITHUB_REF_NAME#v}"
          node scripts/ci/wait-for-npm-packages.mjs "$VERSION" \
            @microsandbox/agent-client @microsandbox/control-client

      - 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
            node ../../../../scripts/ci/publish-npm-package.mjs .
            cd ../../../..
          done

      - name: Wait for npm to index platform packages
        run: |
          VERSION="${GITHUB_REF_NAME#v}"
          node scripts/ci/wait-for-npm-packages.mjs "$VERSION" \
            @superradcompany/microsandbox-darwin-arm64 \
            @superradcompany/microsandbox-linux-arm64-gnu \
            @superradcompany/microsandbox-linux-x64-gnu \
            @superradcompany/microsandbox-win32-arm64-msvc \
            @superradcompany/microsandbox-win32-x64-msvc

      - name: Build TypeScript output with published platform dependencies
        working-directory: sdk/node-ts
        run: |
          # A version bump can precede registry entries for its native packages.
          # Only resolve them after publication, then validate with a clean install.
          npm install --package-lock-only --ignore-scripts
          npm ci --omit=optional
          npm run build:ts

      - 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: node ../../scripts/ci/publish-npm-package.mjs .

  # ---------------------------------------------------------------------------
  # Refresh SDK lockfiles after publication, when the new packages can resolve.
  # Opens a PR for the maintainer to merge.
  # ---------------------------------------------------------------------------
  refresh-lockfile:
    name: Refresh lockfiles
    needs: [npm-publish, crates-publish]
    if: ${{ !cancelled() && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') && (needs.npm-publish.result == 'success' || needs.crates-publish.result == 'success') }}
    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
        if: needs.npm-publish.result == 'success'
        with:
          node-version: 22

      - name: Wait for npm to index platform packages
        if: needs.npm-publish.result == 'success'
        run: |
          VERSION="${GITHUB_REF_NAME#v}"
          node scripts/ci/wait-for-npm-packages.mjs "$VERSION" \
            @superradcompany/microsandbox-darwin-arm64 \
            @superradcompany/microsandbox-linux-arm64-gnu \
            @superradcompany/microsandbox-linux-x64-gnu \
            @superradcompany/microsandbox-win32-arm64-msvc \
            @superradcompany/microsandbox-win32-x64-msvc

      - name: Regenerate npm lockfile
        if: needs.npm-publish.result == 'success'
        working-directory: sdk/node-ts
        run: npm install --package-lock-only --ignore-scripts

      - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
        if: needs.crates-publish.result == 'success'

      - name: Regenerate Ruby extension lockfile
        if: needs.crates-publish.result == 'success'
        working-directory: sdk/ruby/ext/microsandbox
        run: |
          VERSION="${GITHUB_REF_NAME#v}"
          cargo update -p microsandbox --precise "$VERSION"

      - uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
        with:
          title: "chore: refresh lockfiles after ${{ github.ref_name }}"
          body: |
            Regenerates SDK lockfiles against the packages published for ${{ github.ref_name }}.

            ${{ needs.npm-publish.result != 'success' && 'Node lockfile not refreshed: npm publication did not succeed.' || '' }}
            ${{ needs.crates-publish.result != 'success' && 'Ruby lockfile not refreshed: crates.io publication did not succeed.' || '' }}
          branch: refresh-lockfile-${{ github.ref_name }}
          commit-message: "chore: refresh lockfiles after ${{ github.ref_name }}"
          base: main
          add-paths: |
            sdk/node-ts/package-lock.json
            sdk/ruby/ext/microsandbox/Cargo.lock
          delete-branch: true

  # ---------------------------------------------------------------------------
  # Publish MCP server to npm
  # ---------------------------------------------------------------------------
  mcp-publish:
    name: Publish MCP server
    needs: npm-publish
    if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
    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: Wait for the root SDK package
        run: |
          VERSION="${GITHUB_REF_NAME#v}"
          node scripts/ci/wait-for-npm-packages.mjs "$VERSION" microsandbox

      - name: Build MCP server
        working-directory: mcp
        run: |
          # The pinned MCP source has no lockfile. Disable dependency lifecycle
          # scripts during resolution and keep publishing credentials out of build.
          if [ -f package-lock.json ]; then
            npm ci --ignore-scripts
          else
            npm install --ignore-scripts
          fi
          npm run build

      - name: Publish MCP server
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
        working-directory: mcp
        run: node ../scripts/ci/publish-npm-package.mjs .

  # ---------------------------------------------------------------------------
  # Publish Rust crates to crates.io
  # ---------------------------------------------------------------------------
  crates-publish:
    name: Publish crates
    needs: release-ready
    if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

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

      - name: Publish to crates.io
        env:
          CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
        run: python3 scripts/ci/publish-crates.py

  # ---------------------------------------------------------------------------
  # Publish Python SDK to PyPI
  # ---------------------------------------------------------------------------
  pypi-publish:
    name: Publish Python SDK
    needs: release-ready
    if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
    runs-on: ubuntu-latest
    permissions:
      contents: read
      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@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.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
    if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
    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: release-ready
    if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
    runs-on: ${{ matrix.runner }}
    permissions:
      contents: read
      packages: write
    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@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1

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

      - uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.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
    if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
    runs-on: ubuntu-latest
    permissions:
      packages: write
    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
