From cb26b2b19bda46ca3911526b7672595f469386e2 Mon Sep 17 00:00:00 2001 From: sheetalarkadam <100380551+sheetalarkadam@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:10:30 -0700 Subject: [PATCH 1/3] ci(sdk_v2): build the C++ core for Android in the native pipeline Adds two cross-compiled Android stages (arm64-v8a, x86_64) to the sdk_v2 native build graph, running on the existing Linux pool via the NDK. Android resolves its native dependencies from different origins than the desktop platforms: ORT comes from the same Microsoft.ML.OnnxRuntime NuGet package, but GenAI ships as a standalone AAR on GitHub Releases. A version that exists on NuGet therefore does not necessarily exist for Android, and today nothing in CI notices - every desktop leg goes green while the Android build cannot configure at all. The stages publish `cpp-native-android-` artifacts but are deliberately not wired into either pack stage; neither the NuGet package nor the C++ SDK tgz has an Android consumer yet, so packaging is left as a separate question. Build-only for now. Emulator tests are plumbed behind a `runEmulatorTests` parameter on the x86_64 leg and can be enabled once the leg is proven stable. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 24ef037b-246f-4fea-a1a3-4fae5223cb0c --- .../v2/templates/stages-build-native.yml | 69 +++++++ .../v2/templates/steps-build-android.yml | 170 ++++++++++++++++++ 2 files changed, 239 insertions(+) create mode 100644 .pipelines/v2/templates/steps-build-android.yml diff --git a/.pipelines/v2/templates/stages-build-native.yml b/.pipelines/v2/templates/stages-build-native.yml index 38daf4ca..b00bc1c0 100644 --- a/.pipelines/v2/templates/stages-build-native.yml +++ b/.pipelines/v2/templates/stages-build-native.yml @@ -8,6 +8,14 @@ # pack_nuget – Microsoft.AI.Foundry.Local.Runtime # (win-x64, win-arm64, linux-x64, linux-arm64, osx-arm64; # Windows ships WinML; ARM64 Linux is CPU-only) +# +# Android (arm64-v8a, x86_64) is built and published as an artifact but is +# deliberately *not* fed into either pack stage: neither the NuGet package nor +# the C++ SDK tgz has an Android consumer today, and Android's dependency +# origins differ enough (GenAI ships as a GitHub Releases AAR rather than a +# NuGet package) that packaging is a separate design question. The stages exist +# so that a dependency bump which is valid on desktop but unavailable for +# Android fails here rather than silently. parameters: - name: buildConfig @@ -192,6 +200,67 @@ stages: genaiVersion: ${{ parameters.genaiVersion }} runTests: true + # ==================================================================== + # Android arm64-v8a — cross-compiled on a Linux host (build only) + # ==================================================================== + - stage: cpp_build_android_arm64 + displayName: 'C++ Native: Android arm64-v8a' + dependsOn: + - compute_version + jobs: + - job: build + pool: + name: onnxruntime-Ubuntu2404-AMD-CPU + os: linux + templateContext: + inputs: + - input: pipelineArtifact + artifactName: 'version-info' + targetPath: '$(Pipeline.Workspace)/version-info' + outputs: + - output: pipelineArtifact + artifactName: 'cpp-native-android-arm64-v8a' + targetPath: '$(Build.ArtifactStagingDirectory)/native' + steps: + - template: steps-build-android.yml + parameters: + abi: arm64-v8a + buildConfig: ${{ parameters.buildConfig }} + ortVersion: ${{ parameters.ortVersion }} + genaiVersion: ${{ parameters.genaiVersion }} + + # ==================================================================== + # Android x86_64 — cross-compiled on a Linux host (build only) + # + # This is the emulator ABI, so it is the leg that can eventually run tests + # (see steps-build-android.yml's runEmulatorTests). Kept build-only for now. + # ==================================================================== + - stage: cpp_build_android_x86_64 + displayName: 'C++ Native: Android x86_64' + dependsOn: + - compute_version + jobs: + - job: build + pool: + name: onnxruntime-Ubuntu2404-AMD-CPU + os: linux + templateContext: + inputs: + - input: pipelineArtifact + artifactName: 'version-info' + targetPath: '$(Pipeline.Workspace)/version-info' + outputs: + - output: pipelineArtifact + artifactName: 'cpp-native-android-x86_64' + targetPath: '$(Build.ArtifactStagingDirectory)/native' + steps: + - template: steps-build-android.yml + parameters: + abi: x86_64 + buildConfig: ${{ parameters.buildConfig }} + ortVersion: ${{ parameters.ortVersion }} + genaiVersion: ${{ parameters.genaiVersion }} + # ==================================================================== # Pack — C++ SDK tgz bundles (base platforms) # ==================================================================== diff --git a/.pipelines/v2/templates/steps-build-android.yml b/.pipelines/v2/templates/steps-build-android.yml new file mode 100644 index 00000000..6164364e --- /dev/null +++ b/.pipelines/v2/templates/steps-build-android.yml @@ -0,0 +1,170 @@ +# Reusable Android cross-compile steps for the Foundry Local C++ SDK. +# +# Runs on a Linux host and cross-compiles with the Android NDK, so it uses the +# same pool as the linux-x64 stage. Supports arm64-v8a (devices) and x86_64 +# (emulator) via the `abi` parameter. +# +# Android resolves its native dependencies differently from the desktop +# platforms, which is the main reason this stage earns its keep: +# +# ORT – Microsoft.ML.OnnxRuntime NuGet, runtimes/android-{arm64,x64} +# (same package as desktop, so steps-prefetch-nuget.yml applies) +# GenAI – a standalone AAR on *GitHub Releases*, not NuGet +# (see cmake/FindOnnxRuntimeGenAI.cmake) +# +# Because GenAI comes from a different origin, a version that exists on NuGet +# does not necessarily exist as an Android AAR. Without this stage that skew is +# invisible: every desktop leg goes green while Android cannot configure at all. + +parameters: +- name: abi + type: string + values: ['arm64-v8a', 'x86_64'] +- name: buildConfig + type: string +- name: ortVersion + type: string +- name: genaiVersion + type: string +# Minimum supported API level. Keep in sync with build.py's --android_api default. +- name: androidApi + type: number + default: 28 +# Pinned so a pool image refresh cannot silently change the toolchain. +- name: ndkVersion + type: string + default: '29.0.14206865' +# Emulator tests require the x86_64 ABI. Off by default: this stage exists to +# guard the build and the dependency wiring, and an emulator boot adds both +# runtime and flakiness. Enable per-stage once the leg is proven stable. +- name: runEmulatorTests + type: boolean + default: false + +steps: + +- bash: | + set -euo pipefail + git clone https://github.com/microsoft/vcpkg.git "$(Build.BinariesDirectory)/vcpkg" + "$(Build.BinariesDirectory)/vcpkg/bootstrap-vcpkg.sh" -disableMetrics + displayName: 'Bootstrap vcpkg' + +# Resolve the NDK from the pool image when possible, and only fall back to +# sdkmanager if the pinned version is genuinely absent. Both paths converge on +# the same pinned version so the toolchain is identical either way. +- bash: | + set -euo pipefail + pinned='${{ parameters.ndkVersion }}' + sdk="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-}}" + + if [ -z "$sdk" ]; then + echo "ERROR: neither ANDROID_SDK_ROOT nor ANDROID_HOME is set on this agent." >&2 + exit 1 + fi + + ndk="$sdk/ndk/$pinned" + if [ ! -d "$ndk" ]; then + echo "NDK $pinned not present; installing via sdkmanager." + sdkmanager="$sdk/cmdline-tools/latest/bin/sdkmanager" + if [ ! -x "$sdkmanager" ]; then + echo "ERROR: sdkmanager not found at $sdkmanager" >&2 + exit 1 + fi + yes | "$sdkmanager" --install "ndk;$pinned" > /dev/null + fi + + if [ ! -d "$ndk" ]; then + echo "ERROR: NDK $pinned still not found at $ndk" >&2 + exit 1 + fi + + echo "ANDROID_HOME = $sdk" + echo "ANDROID_NDK_HOME = $ndk" + echo "##vso[task.setvariable variable=androidSdkRoot]$sdk" + echo "##vso[task.setvariable variable=androidNdkHome]$ndk" + displayName: 'Resolve Android NDK (${{ parameters.ndkVersion }})' + +# Prefetches ORT (used by the Android build) and GenAI (unused here — Android +# takes the GitHub AAR instead — but harmless, and it keeps the pinned-version +# drift check against deps_versions.json running on this leg too). +- template: steps-prefetch-nuget.yml + parameters: + ortVersion: ${{ parameters.ortVersion }} + genaiVersion: ${{ parameters.genaiVersion }} + winmlVersion: '' + includeWinml: false + shell: bash + +# Bake the pipeline-computed version into the binary so +# FoundryLocalGetVersionString() matches the package version rather than the +# cmake default. Mirrors the desktop legs. +- bash: | + set -euo pipefail + version=$(cat "$(Pipeline.Workspace)/version-info/sdkVersion.txt" | tr -d '[:space:]') + defines="$(cmakeFetchDefines) \"FOUNDRY_LOCAL_VERSION_STRING=$version\"" + echo "##vso[task.setvariable variable=cmakeFetchDefines]$defines" + echo "cmakeFetchDefines = $defines" + displayName: 'Append version define' + +- bash: | + set -euo pipefail + python3 build.py --configure --build \ + --android \ + --android_abi ${{ parameters.abi }} \ + --android_api ${{ parameters.androidApi }} \ + --config ${{ parameters.buildConfig }} \ + --cmake_extra_defines $(cmakeFetchDefines) + displayName: 'Configure and build (${{ parameters.abi }})' + workingDirectory: $(Build.SourcesDirectory)/sdk_v2/cpp + env: + VCPKG_ROOT: $(Build.BinariesDirectory)/vcpkg + ANDROID_HOME: $(androidSdkRoot) + ANDROID_NDK_HOME: $(androidNdkHome) + +- ${{ if eq(parameters.runEmulatorTests, true) }}: + - bash: | + set -euo pipefail + python3 build.py --test \ + --android \ + --android_abi ${{ parameters.abi }} \ + --android_api ${{ parameters.androidApi }} \ + --android_run_emulator \ + --config ${{ parameters.buildConfig }} + displayName: 'Run tests on emulator (${{ parameters.abi }})' + workingDirectory: $(Build.SourcesDirectory)/sdk_v2/cpp + env: + VCPKG_ROOT: $(Build.BinariesDirectory)/vcpkg + ANDROID_HOME: $(androidSdkRoot) + ANDROID_NDK_HOME: $(androidNdkHome) + +- bash: | + echo "=== vcpkg buildtrees error logs ===" + find "$(Build.BinariesDirectory)/vcpkg/buildtrees" -name "*err.log" -exec echo "--- {} ---" \; -exec cat {} \; || true + echo "=== vcpkg buildtrees config output logs ===" + find "$(Build.BinariesDirectory)/vcpkg/buildtrees" -name "config-*-out.log" -exec echo "--- {} ---" \; -exec tail -100 {} \; || true + displayName: 'Dump vcpkg error logs' + condition: failed() + +# Unlike the desktop legs, Android stages all three .so files. Desktop +# consumers get ORT/GenAI out of band (pip on the Python side, NuGet on the C# +# side), but an Android consumer has no such channel — the libraries have to be +# packaged into the APK/AAR alongside libfoundry_local.so, so the build that +# produced them is the only place they can be captured consistently. +- bash: | + set -euo pipefail + src='$(Build.SourcesDirectory)/sdk_v2/cpp/build/Android-${{ parameters.abi }}/${{ parameters.buildConfig }}/bin' + dst='$(Build.ArtifactStagingDirectory)/native' + mkdir -p "$dst" + + missing=0 + for lib in libfoundry_local.so libonnxruntime.so libonnxruntime-genai.so; do + if [ -f "$src/$lib" ]; then + cp -P "$src/$lib" "$dst/" + echo " staged $lib" + else + echo "ERROR: $lib not found at $src/$lib" >&2 + missing=1 + fi + done + [ "$missing" -eq 0 ] || exit 1 + displayName: 'Stage native artifacts' From 0e3f829e440a9b841c696b185d989abc8bc99023 Mon Sep 17 00:00:00 2001 From: sheetalarkadam <100380551+sheetalarkadam@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:11:51 -0700 Subject: [PATCH 2/3] ci(sdk_v2): stage libmat.so for Android and verify DT_NEEDED closure The Android staging step copied three .so files and its comment claimed that was all of them. GenAI 0.15.0 split most of libonnxruntime-genai.so into libmat.so and records it as a DT_NEEDED, so the published artifact was missing a library its own contents require -- a consuming APK would build green and then fail to dlopen at runtime. Stage libmat.so, guarded on existence to mirror the copy in sdk_v2/cpp/CMakeLists.txt so 0.14.x keeps working unchanged. To stop this class of bug returning, assert DT_NEEDED closure over the staged set. The check keys off "the build produced this library in bin/", so system libraries are excluded without a whitelist and it cannot fail spuriously on an agent whose image differs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 24ef037b-246f-4fea-a1a3-4fae5223cb0c --- .../v2/templates/steps-build-android.yml | 40 ++++++++++++++++--- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/.pipelines/v2/templates/steps-build-android.yml b/.pipelines/v2/templates/steps-build-android.yml index 6164364e..deec8910 100644 --- a/.pipelines/v2/templates/steps-build-android.yml +++ b/.pipelines/v2/templates/steps-build-android.yml @@ -145,11 +145,11 @@ steps: displayName: 'Dump vcpkg error logs' condition: failed() -# Unlike the desktop legs, Android stages all three .so files. Desktop -# consumers get ORT/GenAI out of band (pip on the Python side, NuGet on the C# -# side), but an Android consumer has no such channel — the libraries have to be -# packaged into the APK/AAR alongside libfoundry_local.so, so the build that -# produced them is the only place they can be captured consistently. +# Unlike the desktop legs, Android stages the ORT/GenAI runtime alongside +# libfoundry_local.so. Desktop consumers get those out of band (pip on the +# Python side, NuGet on the C# side), but an Android consumer has no such +# channel — the libraries have to be packaged into the APK/AAR, so the build +# that produced them is the only place they can be captured consistently. - bash: | set -euo pipefail src='$(Build.SourcesDirectory)/sdk_v2/cpp/build/Android-${{ parameters.abi }}/${{ parameters.buildConfig }}/bin' @@ -167,4 +167,34 @@ steps: fi done [ "$missing" -eq 0 ] || exit 1 + + # GenAI 0.15.0 split its implementation into libmat.so, which + # libonnxruntime-genai.so lists as a DT_NEEDED — omitting it makes the + # consuming APK fail to dlopen at runtime. Guarded on existence to mirror + # the copy in sdk_v2/cpp/CMakeLists.txt, which keeps 0.14.x working. + if [ -f "$src/libmat.so" ]; then + cp -P "$src/libmat.so" "$dst/" + echo " staged libmat.so" + fi + + # Fail loudly if a staged library has a DT_NEEDED that the build produced + # but we did not stage: a future GenAI repackaging that adds another .so + # would otherwise give a green build and a runtime dlopen failure. Keying + # off "present in the build output" needs no whitelist of system libs — + # those never appear in bin/ — so it cannot fail spuriously on an agent. + readelf=$(command -v readelf || true) + if [ -n "$readelf" ]; then + unmet=0 + for so in "$dst"/*.so; do + for need in $("$readelf" -d "$so" | sed -n 's/.*(NEEDED).*\[\(.*\)\]/\1/p'); do + if [ -f "$src/$need" ] && [ ! -f "$dst/$need" ]; then + echo "ERROR: $(basename "$so") needs $need, which the build produced but this step did not stage" >&2 + unmet=1 + fi + done + done + [ "$unmet" -eq 0 ] || exit 1 + else + echo "NOTE: readelf unavailable; skipped DT_NEEDED closure check." + fi displayName: 'Stage native artifacts' From 491e1c2faf04e12d26d60098b718232c8d7dfdc7 Mon Sep 17 00:00:00 2001 From: sheetalarkadam <100380551+sheetalarkadam@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:35:00 -0700 Subject: [PATCH 3/3] ci(sdk_v2): align the Android arm64 stage id with its artifact name Every other native producer stage uses the same arch token in its stage id and its artifact name (cpp_build_linux_arm64 -> cpp-native-linux-arm64). The Android arm64 stage shortened the ABI to "arm64" while publishing cpp-native-arm64-v8a, and did not even agree with its own x86_64 sibling, which already carries the full ABI string. Renaming is free right now because the stage has never run on main, so no branch policy can reference it as a required check yet. That stops being true once it merges. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 24ef037b-246f-4fea-a1a3-4fae5223cb0c --- .pipelines/v2/templates/stages-build-native.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pipelines/v2/templates/stages-build-native.yml b/.pipelines/v2/templates/stages-build-native.yml index b00bc1c0..369e7f50 100644 --- a/.pipelines/v2/templates/stages-build-native.yml +++ b/.pipelines/v2/templates/stages-build-native.yml @@ -203,7 +203,7 @@ stages: # ==================================================================== # Android arm64-v8a — cross-compiled on a Linux host (build only) # ==================================================================== - - stage: cpp_build_android_arm64 + - stage: cpp_build_android_arm64_v8a displayName: 'C++ Native: Android arm64-v8a' dependsOn: - compute_version