diff --git a/plugin-ep-webgpu/README.md b/plugin-ep-webgpu/README.md
index dd874f8af1c3b..889fef10ae5e1 100644
--- a/plugin-ep-webgpu/README.md
+++ b/plugin-ep-webgpu/README.md
@@ -10,8 +10,12 @@ For more information about plugin EPs, see the documentation [here](https://onnx
- [`VERSION_NUMBER`](VERSION_NUMBER) — Base plugin EP version consumed by the CI pipeline. The pipeline derives the
final package version (release, dev) from this via
[`tools/ci_build/github/azure-pipelines/templates/set-plugin-build-variables-step.yml`](../tools/ci_build/github/azure-pipelines/templates/set-plugin-build-variables-step.yml).
+- [`MIN_ONNXRUNTIME_VERSION`](MIN_ONNXRUNTIME_VERSION) — Minimum compatible core `onnxruntime` version. Single source
+ of truth shared by all packages built from this directory.
- [`python/`](python/) — Sources and build script for the `onnxruntime-ep-webgpu` Python wheel. See
[`python/README.md`](python/README.md) for build and test instructions.
+- [`csharp/`](csharp/) — Sources and packaging script for the `Microsoft.ML.OnnxRuntime.EP.WebGpu` NuGet package. See
+ [`csharp/README.md`](csharp/README.md) for build and test instructions.
## How it fits together
@@ -19,6 +23,7 @@ The plugin EP is built as a shared library (`onnxruntime_providers_webgpu.{dll,s
build (`--use_webgpu shared_lib`). The resulting binaries are then packaged into:
- A Python wheel (`onnxruntime-ep-webgpu`), built from [`python/`](python/).
+- A NuGet package (`Microsoft.ML.OnnxRuntime.EP.WebGpu`), built from [`csharp/`](csharp/).
- A universal package published to the internal ORT-Nightly feed for Windows (x64 / arm64), Linux x64, and macOS
arm64.
@@ -29,7 +34,7 @@ and post-build smoke tests run in the companion `WebGPU Plugin EP Test Pipeline`
## Usage
-Once installed, the plugin EP is registered at runtime:
+Once installed, the plugin EP is registered at runtime. Example in Python:
```python
import onnxruntime as ort
@@ -43,5 +48,7 @@ sess_options.add_provider_for_devices(devices, {})
session = ort.InferenceSession("model.onnx", sess_options=sess_options)
```
-See [`python/onnxruntime_ep_webgpu/README.md`](python/onnxruntime_ep_webgpu/README.md) for the user-facing package
-documentation (this README is bundled into the wheel).
+See the user-facing package READMEs (bundled into the published packages) for full per-language usage:
+
+- Python: [`python/onnxruntime_ep_webgpu/README.md`](python/onnxruntime_ep_webgpu/README.md)
+- C# / .NET: [`csharp/Microsoft.ML.OnnxRuntime.EP.WebGpu/README.md`](csharp/Microsoft.ML.OnnxRuntime.EP.WebGpu/README.md)
diff --git a/plugin-ep-webgpu/csharp/Microsoft.ML.OnnxRuntime.EP.WebGpu/Microsoft.ML.OnnxRuntime.EP.WebGpu.csproj b/plugin-ep-webgpu/csharp/Microsoft.ML.OnnxRuntime.EP.WebGpu/Microsoft.ML.OnnxRuntime.EP.WebGpu.csproj
new file mode 100644
index 0000000000000..94be6bec6ea46
--- /dev/null
+++ b/plugin-ep-webgpu/csharp/Microsoft.ML.OnnxRuntime.EP.WebGpu/Microsoft.ML.OnnxRuntime.EP.WebGpu.csproj
@@ -0,0 +1,91 @@
+
+
+
+ netstandard2.0
+ latest
+ enable
+
+
+ Microsoft.ML.OnnxRuntime.EP.WebGpu
+
+ 0.0.0-dev
+ Microsoft
+ Microsoft
+ ONNX Runtime WebGPU Plugin Execution Provider.
+ README.md
+ ONNX;ONNX Runtime;Machine Learning;AI;Deep Learning;WebGPU
+
+
+ MIT
+ https://github.com/microsoft/onnxruntime
+ git
+ © Microsoft Corporation. All rights reserved.
+
+
+ true
+ snupkg
+
+
+
+
+ $(MSBuildThisFileDirectory)..\..\MIN_ONNXRUNTIME_VERSION
+ $([System.IO.File]::ReadAllText('$(OnnxRuntimeMinVersionFile)').Trim())
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/plugin-ep-webgpu/csharp/Microsoft.ML.OnnxRuntime.EP.WebGpu/README.md b/plugin-ep-webgpu/csharp/Microsoft.ML.OnnxRuntime.EP.WebGpu/README.md
new file mode 100644
index 0000000000000..f4a717b8836d5
--- /dev/null
+++ b/plugin-ep-webgpu/csharp/Microsoft.ML.OnnxRuntime.EP.WebGpu/README.md
@@ -0,0 +1,42 @@
+## Microsoft.ML.OnnxRuntime.EP.WebGpu
+
+WebGPU plugin Execution Provider for [ONNX Runtime](https://github.com/microsoft/onnxruntime).
+
+### Usage
+
+```csharp
+// Note: Error handling is omitted for brevity.
+
+using Microsoft.ML.OnnxRuntime;
+using Microsoft.ML.OnnxRuntime.EP.WebGpu;
+
+// Register the WebGPU EP plugin library
+var env = OrtEnv.Instance();
+env.RegisterExecutionProviderLibrary("webgpu_ep", WebGpuEp.GetLibraryPath());
+
+// Find the WebGPU EP device
+OrtEpDevice? webGpuDevice = null;
+foreach (var d in env.GetEpDevices())
+{
+ if (d.EpName == WebGpuEp.GetEpName())
+ {
+ webGpuDevice = d;
+ break;
+ }
+}
+
+// Create a session with the WebGPU EP
+using var sessionOptions = new SessionOptions();
+sessionOptions.AppendExecutionProvider(env, new[] { webGpuDevice }, new Dictionary());
+
+using var session = new InferenceSession("model.onnx", sessionOptions);
+```
+
+### Supported Platforms
+
+| Runtime Identifier | Native Library |
+|---|---|
+| win-x64 | `onnxruntime_providers_webgpu.dll`, `dxil.dll`, `dxcompiler.dll` |
+| win-arm64 | `onnxruntime_providers_webgpu.dll`, `dxil.dll`, `dxcompiler.dll` |
+| linux-x64 | `libonnxruntime_providers_webgpu.so` |
+| osx-arm64 | `libonnxruntime_providers_webgpu.dylib` |
diff --git a/plugin-ep-webgpu/csharp/Microsoft.ML.OnnxRuntime.EP.WebGpu/WebGpuEp.cs b/plugin-ep-webgpu/csharp/Microsoft.ML.OnnxRuntime.EP.WebGpu/WebGpuEp.cs
new file mode 100644
index 0000000000000..2a5ec106aad0d
--- /dev/null
+++ b/plugin-ep-webgpu/csharp/Microsoft.ML.OnnxRuntime.EP.WebGpu/WebGpuEp.cs
@@ -0,0 +1,112 @@
+using System;
+using System.IO;
+using System.Runtime.InteropServices;
+
+namespace Microsoft.ML.OnnxRuntime.EP.WebGpu
+{
+ ///
+ /// Provides helper methods to locate the WebGPU plugin EP native library
+ /// and retrieve the EP name for registration with ONNX Runtime.
+ ///
+ public static class WebGpuEp
+ {
+ ///
+ /// Returns the path to the WebGPU plugin EP native library contained by this package.
+ /// Can be passed to OrtEnv.RegisterExecutionProviderLibrary().
+ ///
+ /// Full path to the EP native library.
+ /// If the native library file does not exist at the expected path.
+ public static string GetLibraryPath()
+ {
+ string rootDir = GetNativeDirectory();
+ string rid = GetRuntimeIdentifier();
+ string libraryName = GetLibraryName();
+
+ // Probe the standard NuGet runtimes//native/ layout first, then fall back
+ // to the base directory for single-file/published layouts where native assets
+ // can land directly next to the managed assembly.
+ string[] candidates =
+ {
+ Path.Combine(rootDir, "runtimes", rid, "native", libraryName),
+ Path.Combine(rootDir, libraryName),
+ };
+
+ foreach (var candidate in candidates)
+ {
+ if (File.Exists(candidate))
+ return Path.GetFullPath(candidate);
+ }
+
+ throw new FileNotFoundException(
+ $"Did not find WebGPU EP library file. Probed: {string.Join(", ", candidates)}");
+ }
+
+ ///
+ /// Returns the names of the EPs created by the WebGPU plugin EP library.
+ /// Can be used to select an OrtEpDevice from those returned by OrtEnv.GetEpDevices().
+ ///
+ /// Array of EP names.
+ public static string[] GetEpNames()
+ {
+ return new[] { GetEpName() };
+ }
+
+ ///
+ /// Returns the name of the one EP supported by this plugin EP library.
+ /// Convenience method for plugin EP packages that expose a single EP.
+ ///
+ /// The EP name string.
+ public static string GetEpName()
+ {
+ return "WebGpuExecutionProvider";
+ }
+
+ private static string GetNativeDirectory()
+ {
+ var assemblyDir = Path.GetDirectoryName(typeof(WebGpuEp).Assembly.Location);
+
+ if (!string.IsNullOrEmpty(assemblyDir) && Directory.Exists(assemblyDir))
+ return assemblyDir;
+
+ return AppContext.BaseDirectory;
+ }
+
+ private static string GetRuntimeIdentifier()
+ {
+ return GetOSTag() + "-" + GetArchTag();
+ }
+
+ private static string GetLibraryName()
+ {
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ return "onnxruntime_providers_webgpu.dll";
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
+ return "libonnxruntime_providers_webgpu.so";
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
+ return "libonnxruntime_providers_webgpu.dylib";
+
+ throw new PlatformNotSupportedException(
+ $"WebGPU plugin EP does not support OS platform: {RuntimeInformation.OSDescription}");
+ }
+
+ private static string GetOSTag()
+ {
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return "win";
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) return "linux";
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) return "osx";
+ throw new PlatformNotSupportedException(
+ $"WebGPU plugin EP does not support OS platform: {RuntimeInformation.OSDescription}");
+ }
+
+ private static string GetArchTag()
+ {
+ return RuntimeInformation.ProcessArchitecture switch
+ {
+ Architecture.X64 => "x64",
+ Architecture.Arm64 => "arm64",
+ _ => throw new PlatformNotSupportedException(
+ $"WebGPU plugin EP does not support process architecture: {RuntimeInformation.ProcessArchitecture}"),
+ };
+ }
+ }
+}
diff --git a/plugin-ep-webgpu/csharp/README.md b/plugin-ep-webgpu/csharp/README.md
new file mode 100644
index 0000000000000..7a2b2041e364f
--- /dev/null
+++ b/plugin-ep-webgpu/csharp/README.md
@@ -0,0 +1,140 @@
+# WebGPU Plugin EP — NuGet Packaging
+
+This directory contains the C# NuGet package project and test app for the WebGPU plugin Execution Provider.
+
+## Directory Structure
+
+```
+csharp/
+├── pack_nuget.py # Helper script to build the NuGet package
+├── Microsoft.ML.OnnxRuntime.EP.WebGpu/
+│ ├── Microsoft.ML.OnnxRuntime.EP.WebGpu.csproj # NuGet package project (netstandard2.0)
+│ ├── WebGpuEp.cs # Helper class for native library resolution
+│ └── README.md # Package readme (shipped inside .nupkg)
+└── test/
+ └── WebGpuEpNuGetTest/
+ ├── WebGpuEpNuGetTest.csproj # Test console app (net8.0)
+ ├── Program.cs # Registers EP, runs inference, validates output
+ ├── mul.onnx # Test model (element-wise multiply)
+ └── generate_mul_model.py # Script to regenerate mul.onnx
+```
+
+## Prerequisites
+
+- .NET SDK 8.0 or later
+- A built WebGPU plugin EP shared library
+
+## Building the NuGet Package
+
+Use `pack_nuget.py` to stage native binaries and run `dotnet pack`. The script copies everything into a staging
+directory before building — the source tree is never modified. By default, an auto-cleaned temporary directory is used;
+pass `--staging-dir` to use an explicit one (required when running with `--build-only` or `--pack-only`).
+
+At least one binary directory (or `--artifacts-dir` with matching subdirectories) must be provided. Platforms without
+a binary directory are skipped. Run `python pack_nuget.py --help` for the full list of options and their defaults.
+
+### Pack with a local build (single platform)
+
+```powershell
+cd plugin-ep-webgpu/csharp
+
+python pack_nuget.py --version 0.1.0-dev `
+ --binary-dir-win-x64
+```
+
+### Pack multiple platforms
+
+Each `--binary-dir-*` points at the directory containing that platform's already-built native binaries. In practice
+the four binaries are produced on different machines and combined in CI; locally you'd typically only set the one(s)
+you have available.
+
+```powershell
+python pack_nuget.py --version 0.1.0-dev `
+ --binary-dir-win-x64 `
+ --binary-dir-win-arm64 `
+ --binary-dir-linux-x64 `
+ --binary-dir-macos-arm64
+```
+
+## Versioning
+
+The package version is supplied to `pack_nuget.py` via `--version`. In the packaging pipeline, the release or
+pre-release version is derived from [`plugin-ep-webgpu/VERSION_NUMBER`](../VERSION_NUMBER).
+
+## Inspecting the Package
+
+The `.nupkg` is a ZIP file. To verify its contents:
+
+```powershell
+Expand-Archive nuget_output/Microsoft.ML.OnnxRuntime.EP.WebGpu.0.1.0-dev.nupkg `
+ -DestinationPath nuget_output/inspect -Force
+
+Get-ChildItem nuget_output/inspect -Recurse | Select-Object FullName
+```
+
+Expected layout inside the package:
+
+```
+lib/netstandard2.0/Microsoft.ML.OnnxRuntime.EP.WebGpu.dll
+runtimes/win-x64/native/onnxruntime_providers_webgpu.dll
+runtimes/win-x64/native/dxil.dll
+runtimes/win-x64/native/dxcompiler.dll
+runtimes/win-arm64/native/...
+runtimes/linux-x64/native/libonnxruntime_providers_webgpu.so
+runtimes/osx-arm64/native/libonnxruntime_providers_webgpu.dylib
+```
+
+## Testing the Package
+
+The test app registers the WebGPU EP, creates a session, runs a simple Mul model, and validates the output.
+
+```powershell
+# Point the test project's nuget.config at the pack output
+$localFeed = (Resolve-Path nuget_output).Path
+@"
+
+
+
+
+
+
+
+
+"@ | Set-Content test/WebGpuEpNuGetTest/nuget.config
+
+# Build and run
+dotnet run --project test/WebGpuEpNuGetTest/WebGpuEpNuGetTest.csproj --configuration Release
+```
+
+A successful run prints `PASSED: All outputs match expected values.` and exits with code 0.
+
+## Regenerating the Test Model
+
+```bash
+python test/WebGpuEpNuGetTest/generate_mul_model.py
+```
+
+Requires the `onnx` Python package.
+
+## CI Pipeline
+
+The NuGet packaging is integrated into the WebGPU plugin pipeline:
+
+- **Pipeline:** `tools/ci_build/github/azure-pipelines/plugin-webgpu-pipeline.yml`
+- **Packaging stage:** `tools/ci_build/github/azure-pipelines/stages/plugin-webgpu-nuget-packaging-stage.yml`
+
+The CI stage downloads build artifacts from all enabled platform stages, invokes `pack_nuget.py`, ESRP-signs the
+package, and runs the test app on a GPU agent.
+
+## Native Binaries Per Platform
+
+| RID | Required Files |
+|---|---|
+| `win-x64` | `onnxruntime_providers_webgpu.dll`, `dxil.dll`, `dxcompiler.dll` |
+| `win-arm64` | `onnxruntime_providers_webgpu.dll`, `dxil.dll`, `dxcompiler.dll` |
+| `linux-x64` | `libonnxruntime_providers_webgpu.so` |
+| `osx-arm64` | `libonnxruntime_providers_webgpu.dylib` |
+
+On Windows, `dxil.dll` and `dxcompiler.dll` are the DirectX Shader Compiler binaries downloaded from the
+[DXC GitHub releases](https://github.com/microsoft/DirectXShaderCompiler/releases). The CI pipeline handles this
+automatically.
diff --git a/plugin-ep-webgpu/csharp/pack_nuget.py b/plugin-ep-webgpu/csharp/pack_nuget.py
new file mode 100644
index 0000000000000..9a29d067a4034
--- /dev/null
+++ b/plugin-ep-webgpu/csharp/pack_nuget.py
@@ -0,0 +1,336 @@
+#!/usr/bin/env python3
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License.
+"""Build the Microsoft.ML.OnnxRuntime.EP.WebGpu NuGet package.
+
+Stages native binaries from build artifacts into the runtimes/ layout expected
+by the .csproj and runs `dotnet pack` to produce the .nupkg / .snupkg files.
+
+Can be invoked locally or from CI. In CI, pass --artifacts-dir to point at the
+downloaded pipeline artifacts. Locally, pass individual --binary-dir-* options.
+
+Examples
+--------
+Local: pack win-x64 only from a local build:
+
+ python pack_nuget.py --version 0.1.0-dev \\
+ --binary-dir-win-x64 ../../build/webgpu.plugin/Release/Release
+
+CI: pack all platforms from downloaded artifacts:
+
+ python pack_nuget.py --version $(PluginPackageVersion) \\
+ --artifacts-dir $(Build.BinariesDirectory)/artifacts \\
+ --output-dir $(Build.ArtifactStagingDirectory)/nuget
+"""
+
+from __future__ import annotations
+
+import argparse
+import shutil
+import subprocess
+import sys
+import tempfile
+from pathlib import Path
+
+# Platform name -> (RID, list of native binary filenames expected in the source dir).
+PLATFORMS: dict[str, tuple[str, tuple[str, ...]]] = {
+ "win_x64": ("win-x64", ("onnxruntime_providers_webgpu.dll", "dxil.dll", "dxcompiler.dll")),
+ "win_arm64": ("win-arm64", ("onnxruntime_providers_webgpu.dll", "dxil.dll", "dxcompiler.dll")),
+ "linux_x64": ("linux-x64", ("libonnxruntime_providers_webgpu.so",)),
+ "macos_arm64": ("osx-arm64", ("libonnxruntime_providers_webgpu.dylib",)),
+}
+
+SCRIPT_DIR = Path(__file__).resolve().parent
+PROJECT_DIR = SCRIPT_DIR / "Microsoft.ML.OnnxRuntime.EP.WebGpu"
+CSPROJ = PROJECT_DIR / "Microsoft.ML.OnnxRuntime.EP.WebGpu.csproj"
+MIN_ORT_VERSION_FILE = SCRIPT_DIR.parent / "MIN_ONNXRUNTIME_VERSION"
+
+
+class PackError(RuntimeError):
+ """Raised for any user-actionable failure during packaging."""
+
+
+def parse_args() -> argparse.Namespace:
+ def _absolute_path(value: str) -> Path:
+ """argparse `type` converter: parse a string as an absolute Path."""
+ return Path(value).resolve()
+
+ p = argparse.ArgumentParser(
+ description="Build the Microsoft.ML.OnnxRuntime.EP.WebGpu NuGet package.",
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ )
+ p.add_argument("--version", required=True, help="Package version (e.g. 0.1.0-dev).")
+ p.add_argument(
+ "--output-dir",
+ type=_absolute_path,
+ default=(SCRIPT_DIR / "nuget_output").resolve(),
+ help="Directory for the .nupkg / .snupkg output (default: ./nuget_output).",
+ )
+ p.add_argument("--configuration", default="Release", help="Build configuration (default: Release).")
+
+ # CI mode: a single root containing per-platform subdirectories.
+ p.add_argument(
+ "--artifacts-dir",
+ type=_absolute_path,
+ help="CI mode: root containing /bin/ subdirectories for each platform.",
+ )
+
+ # Local mode: explicit per-platform binary directories. Each takes precedence over
+ # --artifacts-dir for that platform.
+ for name in PLATFORMS:
+ flag = f"--binary-dir-{name.replace('_', '-')}"
+ p.add_argument(flag, type=_absolute_path, dest=f"binary_dir_{name}", help=f"Path to {name} native binaries.")
+
+ p.add_argument(
+ "--nuget-config", type=_absolute_path, help="Optional NuGet.config passed to dotnet via --configfile."
+ )
+ p.add_argument(
+ "--staging-dir",
+ type=_absolute_path,
+ help=(
+ "Explicit staging directory. Required with --build-only / --pack-only "
+ "(caller owns its lifecycle). When omitted, an auto-cleaned temporary "
+ "directory is used for the full build+pack flow."
+ ),
+ )
+
+ phase = p.add_mutually_exclusive_group()
+ phase.add_argument(
+ "--build-only",
+ action="store_true",
+ help="Stage and build the managed DLL only; skip dotnet pack. Preserves the staging dir.",
+ )
+ phase.add_argument(
+ "--pack-only",
+ action="store_true",
+ help="Skip staging/build and run dotnet pack against an existing staging directory.",
+ )
+
+ p.add_argument(
+ "--required-platforms",
+ default="",
+ help=(
+ "Comma-separated list of platforms that MUST be staged successfully. "
+ "When omitted, the script just requires at least one platform to be staged."
+ ),
+ )
+
+ return p.parse_args()
+
+
+def parse_required_platforms(value: str) -> list[str]:
+ names = [tok.strip() for tok in value.split(",") if tok.strip()]
+ invalid = [n for n in names if n not in PLATFORMS]
+ if invalid:
+ raise PackError(
+ f"unknown platform(s) in --required-platforms: {', '.join(invalid)}. valid: {', '.join(PLATFORMS)}."
+ )
+ return names
+
+
+def stage_sources(staging_dir: Path) -> None:
+ """Copy project sources into staging, excluding bin/obj."""
+ print(f"Staging project files to {staging_dir}")
+ if staging_dir.exists():
+ shutil.rmtree(staging_dir)
+ shutil.copytree(
+ PROJECT_DIR,
+ staging_dir,
+ ignore=shutil.ignore_patterns("bin", "obj"),
+ )
+
+
+def resolve_platform_source(
+ name: str,
+ binary_dir_override: Path | None,
+ artifacts_dir: Path | None,
+ is_required: bool,
+) -> Path | None:
+ """Return the source dir for a platform, or None to skip."""
+ if binary_dir_override is not None:
+ return binary_dir_override
+ if artifacts_dir is not None:
+ candidate = artifacts_dir / name / "bin"
+ if candidate.is_dir():
+ return candidate
+ if is_required:
+ raise PackError(f"required platform '{name}' artifact directory not found: {candidate}")
+ if is_required:
+ raise PackError(
+ f"required platform '{name}' has no binary directory "
+ f"(pass --binary-dir-{name.replace('_', '-')} or --artifacts-dir)."
+ )
+ return None
+
+
+def stage_binaries(
+ staging_dir: Path,
+ args: argparse.Namespace,
+ required_platforms: list[str],
+) -> None:
+ staged: set[str] = set()
+
+ for name, (rid, files) in PLATFORMS.items():
+ binary_dir_override: Path | None = getattr(args, f"binary_dir_{name}")
+ is_required = name in required_platforms
+ source_dir = resolve_platform_source(name, binary_dir_override, args.artifacts_dir, is_required)
+ if source_dir is None:
+ print(f"Skipping {name} (no binary directory provided)")
+ continue
+ if not source_dir.is_dir():
+ raise PackError(f"binary directory does not exist: {source_dir}")
+
+ target_dir = staging_dir / "runtimes" / rid / "native"
+ target_dir.mkdir(parents=True, exist_ok=True)
+
+ print(f"Staging {name} -> runtimes/{rid}/native/")
+ for filename in files:
+ src = source_dir / filename
+ if not src.is_file():
+ raise PackError(f"expected binary not found: {src}")
+ shutil.copy2(src, target_dir / filename)
+ print(f" {filename}")
+ staged.add(name)
+
+ if required_platforms:
+ missing = [n for n in required_platforms if n not in staged]
+ if missing:
+ raise PackError(f"required platforms not staged: {', '.join(missing)}")
+ elif not staged:
+ raise PackError("no platform binaries were staged. Provide at least one --binary-dir-* or --artifacts-dir.")
+
+ print()
+ print("Runtimes layout:")
+ for path in sorted((staging_dir / "runtimes").rglob("*")):
+ print(f" {path}")
+
+
+def dotnet_common_args(
+ staged_csproj: Path,
+ args: argparse.Namespace,
+ min_ort_version_file: Path,
+) -> list[str]:
+ common = [
+ str(staged_csproj),
+ "--configuration",
+ args.configuration,
+ f"-p:Version={args.version}",
+ f"-p:OnnxRuntimeMinVersionFile={min_ort_version_file}",
+ ]
+ if args.nuget_config:
+ common.extend(["--configfile", str(args.nuget_config)])
+ print(f"Using NuGet.config: {args.nuget_config}")
+ return common
+
+
+def do_build(staged_csproj: Path, staging_dir: Path, args: argparse.Namespace, min_ort_version_file: Path) -> None:
+ print()
+ print(f"Running dotnet build (Version={args.version}, Configuration={args.configuration})...")
+ cmd = ["dotnet", "build", *dotnet_common_args(staged_csproj, args, min_ort_version_file)]
+ print("+ " + " ".join(cmd))
+ subprocess.run(cmd, check=True)
+
+ # Note: "netstandard2.0" must match in Microsoft.ML.OnnxRuntime.EP.WebGpu.csproj.
+ managed_dll = staging_dir / "bin" / args.configuration / "netstandard2.0" / "Microsoft.ML.OnnxRuntime.EP.WebGpu.dll"
+ if not managed_dll.is_file():
+ raise PackError(f"managed DLL not found after build: {managed_dll}")
+ print()
+ print(f"Built managed DLL: {managed_dll}")
+ print("Staging directory preserved for subsequent --pack-only invocation.")
+
+
+def do_pack(
+ staged_csproj: Path,
+ output_dir: Path,
+ args: argparse.Namespace,
+ min_ort_version_file: Path,
+) -> None:
+ print()
+ print(f"Running dotnet pack (Version={args.version}, Configuration={args.configuration})...")
+ pack_args = [
+ "dotnet",
+ "pack",
+ *dotnet_common_args(staged_csproj, args, min_ort_version_file),
+ "--output",
+ str(output_dir),
+ ]
+ if args.pack_only:
+ pack_args.append("--no-build")
+ print("+ " + " ".join(pack_args))
+ subprocess.run(pack_args, check=True)
+
+ print()
+ nupkgs = sorted(output_dir.glob("*.nupkg"))
+ if not nupkgs:
+ raise PackError(f"no .nupkg files found in {output_dir}")
+ for pkg in nupkgs:
+ print(f"Produced: {pkg.name} ({pkg.stat().st_size / (1024 * 1024):.2f} MB)")
+ for pkg in sorted(output_dir.glob("*.snupkg")):
+ print(f"Produced: {pkg.name} ({pkg.stat().st_size / (1024 * 1024):.2f} MB)")
+
+
+def run_in_staging(args: argparse.Namespace, staging_dir: Path, min_ort_version_file: Path) -> None:
+ staged_csproj = staging_dir / "Microsoft.ML.OnnxRuntime.EP.WebGpu.csproj"
+ output_dir: Path = args.output_dir
+ output_dir.mkdir(parents=True, exist_ok=True)
+ required_platforms = parse_required_platforms(args.required_platforms)
+
+ if args.pack_only:
+ if not staged_csproj.is_file():
+ raise PackError(f"staged project not found at {staged_csproj}. Run with --build-only first.")
+ print(f"Reusing existing staging directory: {staging_dir}")
+ else:
+ stage_sources(staging_dir)
+ stage_binaries(staging_dir, args, required_platforms)
+
+ if args.build_only:
+ do_build(staged_csproj, staging_dir, args, min_ort_version_file)
+ return
+
+ do_pack(staged_csproj, output_dir, args, min_ort_version_file)
+
+ print()
+ print(f"Done. Output: {output_dir}")
+
+
+def run(args: argparse.Namespace) -> None:
+ if not CSPROJ.is_file():
+ raise PackError(f"project file not found: {CSPROJ}")
+ if not MIN_ORT_VERSION_FILE.is_file():
+ raise PackError(f"MIN_ONNXRUNTIME_VERSION file not found: {MIN_ORT_VERSION_FILE}")
+ if args.nuget_config and not args.nuget_config.is_file():
+ raise PackError(f"NuGet.config not found: {args.nuget_config}")
+
+ if (args.build_only or args.pack_only) and not args.staging_dir:
+ raise PackError("--staging-dir is required when using --build-only or --pack-only.")
+
+ min_ort_version_file = MIN_ORT_VERSION_FILE.resolve()
+
+ if args.staging_dir:
+ staging_dir: Path = args.staging_dir
+ staging_dir.mkdir(parents=True, exist_ok=True)
+ run_in_staging(args, staging_dir, min_ort_version_file)
+ return
+
+ # Full build+pack flow with no caller-managed staging dir: use a temp dir that
+ # is cleaned up automatically (including on exception).
+ with tempfile.TemporaryDirectory(prefix="webgpu_pack_") as tmp:
+ run_in_staging(args, Path(tmp), min_ort_version_file)
+
+
+def main() -> int:
+ args = parse_args()
+ try:
+ run(args)
+ except PackError as e:
+ print(f"error: {e}", file=sys.stderr)
+ return 1
+ except subprocess.CalledProcessError as e:
+ cmd_name = e.cmd[0] if e.cmd else "subprocess"
+ print(f"error: {cmd_name} failed with exit code {e.returncode}", file=sys.stderr)
+ return e.returncode or 1
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/plugin-ep-webgpu/csharp/test/WebGpuEpNuGetTest/Program.cs b/plugin-ep-webgpu/csharp/test/WebGpuEpNuGetTest/Program.cs
new file mode 100644
index 0000000000000..f5d1f0628c831
--- /dev/null
+++ b/plugin-ep-webgpu/csharp/test/WebGpuEpNuGetTest/Program.cs
@@ -0,0 +1,82 @@
+using Microsoft.ML.OnnxRuntime;
+using Microsoft.ML.OnnxRuntime.EP.WebGpu;
+
+class Program
+{
+ static int Main()
+ {
+ string epLibPath = WebGpuEp.GetLibraryPath();
+ string epRegistrationName = "webgpu_ep_registration";
+ string epName = WebGpuEp.GetEpName();
+
+ Console.WriteLine($"WebGPU EP library path: {epLibPath}");
+
+ var env = OrtEnv.Instance();
+ env.RegisterExecutionProviderLibrary(epRegistrationName, epLibPath);
+ Console.WriteLine($"Registered EP library: {epLibPath}");
+
+ try
+ {
+ // Find the OrtEpDevice for the WebGPU EP
+ OrtEpDevice? epDevice = null;
+ foreach (var d in env.GetEpDevices())
+ {
+ if (string.Equals(epName, d.EpName, StringComparison.Ordinal))
+ {
+ epDevice = d;
+ break;
+ }
+ }
+
+ if (epDevice == null)
+ {
+ Console.Error.WriteLine($"ERROR: Unable to find OrtEpDevice with name '{epName}'");
+ return 1;
+ }
+ Console.WriteLine($"Found OrtEpDevice for EP: {epName}");
+
+ // Create session with WebGPU EP
+ using var sessionOptions = new SessionOptions();
+ sessionOptions.AppendExecutionProvider(env, new[] { epDevice }, new Dictionary());
+ sessionOptions.AddSessionConfigEntry("session.disable_cpu_ep_fallback", "1");
+
+ string inputModelPath = Path.Combine(AppContext.BaseDirectory, "mul.onnx");
+ Console.WriteLine($"Loading model: {inputModelPath}");
+
+ using var session = new InferenceSession(inputModelPath, sessionOptions);
+
+ // Run model: mul(x, y) = x * y
+ float[] inputData = { 1.0f, 2.0f, 3.0f, 4.0f, 5.0f, 6.0f };
+ using var inputOrtValue = OrtValue.CreateTensorValueFromMemory(inputData, new long[] { 2, 3 });
+ var inputValues = new List { inputOrtValue, inputOrtValue }.AsReadOnly();
+ var inputNames = new List { "x", "y" }.AsReadOnly();
+ using var runOptions = new RunOptions();
+
+ using var outputs = session.Run(runOptions, inputNames, inputValues, session.OutputNames);
+
+ float[] expected = { 1.0f, 4.0f, 9.0f, 16.0f, 25.0f, 36.0f };
+ var actual = outputs[0].GetTensorDataAsSpan().ToArray();
+
+ Console.WriteLine($"Input: {string.Join(", ", inputData)}");
+ Console.WriteLine($"Output: {string.Join(", ", actual)}");
+ Console.WriteLine($"Expected: {string.Join(", ", expected)}");
+
+ // Validate output
+ for (int i = 0; i < expected.Length; i++)
+ {
+ if (Math.Abs(actual[i] - expected[i]) > 1e-5f)
+ {
+ Console.Error.WriteLine($"ERROR: Output mismatch at index {i}: expected {expected[i]}, got {actual[i]}");
+ return 1;
+ }
+ }
+
+ Console.WriteLine("PASSED: All outputs match expected values.");
+ return 0;
+ }
+ finally
+ {
+ env.UnregisterExecutionProviderLibrary(epRegistrationName);
+ }
+ }
+}
diff --git a/plugin-ep-webgpu/csharp/test/WebGpuEpNuGetTest/WebGpuEpNuGetTest.csproj b/plugin-ep-webgpu/csharp/test/WebGpuEpNuGetTest/WebGpuEpNuGetTest.csproj
new file mode 100644
index 0000000000000..9554161b1e978
--- /dev/null
+++ b/plugin-ep-webgpu/csharp/test/WebGpuEpNuGetTest/WebGpuEpNuGetTest.csproj
@@ -0,0 +1,34 @@
+
+
+
+ Exe
+ net8.0
+ latest
+ enable
+ enable
+
+ *-*
+
+
+
+
+
+
+
+
+
+ PreserveNewest
+
+
+
+
+
+
+
diff --git a/plugin-ep-webgpu/csharp/test/WebGpuEpNuGetTest/generate_mul_model.py b/plugin-ep-webgpu/csharp/test/WebGpuEpNuGetTest/generate_mul_model.py
new file mode 100644
index 0000000000000..c64b4b7ec96bc
--- /dev/null
+++ b/plugin-ep-webgpu/csharp/test/WebGpuEpNuGetTest/generate_mul_model.py
@@ -0,0 +1,25 @@
+"""Generate a simple Mul ONNX model for testing.
+
+Produces mul.onnx in the same directory as this script.
+The model computes z = x * y (element-wise) for float32 tensors of shape [2, 3].
+"""
+
+import os
+
+from onnx import TensorProto, checker, helper, save
+
+X = helper.make_tensor_value_info("x", TensorProto.FLOAT, [2, 3])
+Y = helper.make_tensor_value_info("y", TensorProto.FLOAT, [2, 3])
+Z = helper.make_tensor_value_info("z", TensorProto.FLOAT, [2, 3])
+
+mul_node = helper.make_node("Mul", inputs=["x", "y"], outputs=["z"])
+
+graph = helper.make_graph([mul_node], "mul_graph", [X, Y], [Z])
+model = helper.make_model(graph, producer_name="onnxruntime-webgpu-ep-test")
+model.opset_import[0].version = 13
+
+checker.check_model(model)
+
+output_path = os.path.join(os.path.dirname(__file__), "mul.onnx")
+save(model, output_path)
+print(f"Saved {output_path}")
diff --git a/plugin-ep-webgpu/csharp/test/WebGpuEpNuGetTest/mul.onnx b/plugin-ep-webgpu/csharp/test/WebGpuEpNuGetTest/mul.onnx
new file mode 100644
index 0000000000000..6df01feb5cf58
--- /dev/null
+++ b/plugin-ep-webgpu/csharp/test/WebGpuEpNuGetTest/mul.onnx
@@ -0,0 +1,16 @@
+
onnxruntime-webgpu-ep-test:Z
+
+x
+yz"Mul mul_graphZ
+x
+
+
+Z
+y
+
+
+b
+z
+
+
+B
\ No newline at end of file
diff --git a/plugin-ep-webgpu/python/README.md b/plugin-ep-webgpu/python/README.md
index ac14a84a70f48..849105a439396 100644
--- a/plugin-ep-webgpu/python/README.md
+++ b/plugin-ep-webgpu/python/README.md
@@ -19,19 +19,13 @@ Wheels are built via `build_wheel.py`. Running `pip install` or `pip wheel` dire
supported — the source tree contains `pyproject.toml.in` (a template), not a real `pyproject.toml`.
```bash
-python build_wheel.py \
- --binary_dir \
- --version \
- --output_dir
+python build_wheel.py --binary_dir --version --output_dir
```
Example:
```bash
-python build_wheel.py \
- --binary_dir ./build/Release \
- --version 0.1.0.dev20260429 \
- --output_dir ./dist
+python build_wheel.py --binary_dir ./build/Release --version 0.1.0.devYYYYMMDD --output_dir ./dist
```
The script combines the pre-built plugin EP binaries with the package source to produce a platform-specific wheel.
@@ -44,7 +38,7 @@ Install the wheel and dependencies in a clean environment, then run the smoke te
python -m venv test_venv
source test_venv/bin/activate # or test_venv\Scripts\Activate.ps1 on Windows
pip install onnx numpy
-pip install dist/onnxruntime_ep_webgpu-*.whl # pulls in onnxruntime>=1.24.4
+pip install dist/onnxruntime_ep_webgpu-*.whl # pulls in the minimum compatible onnxruntime
python test/test_webgpu_plugin_ep.py
```
diff --git a/plugin-ep-webgpu/python/build_wheel.py b/plugin-ep-webgpu/python/build_wheel.py
index 8f855a5d2179b..b4357bcdfbe0f 100644
--- a/plugin-ep-webgpu/python/build_wheel.py
+++ b/plugin-ep-webgpu/python/build_wheel.py
@@ -86,6 +86,7 @@ def prepare_staging_dir(staging_dir: Path, binary_dir: Path, version: str):
shutil.copytree(SCRIPT_DIR / "onnxruntime_ep_webgpu", staging_dir / "onnxruntime_ep_webgpu")
# Copy plugin binaries into the package directory
+ # Note: The binaries are assumed to be directly under `binary_dir`.
package_dir = staging_dir / "onnxruntime_ep_webgpu"
copied = []
for pattern in BINARY_PATTERNS:
diff --git a/tools/ci_build/github/azure-pipelines/plugin-webgpu-pipeline.yml b/tools/ci_build/github/azure-pipelines/plugin-webgpu-pipeline.yml
index 7d9f7c24b3360..673452d8b110a 100644
--- a/tools/ci_build/github/azure-pipelines/plugin-webgpu-pipeline.yml
+++ b/tools/ci_build/github/azure-pipelines/plugin-webgpu-pipeline.yml
@@ -46,7 +46,7 @@ parameters:
type: string
values:
- release
- - RC
+ # - RC # not implemented yet
- dev
default: dev
diff --git a/tools/ci_build/github/azure-pipelines/stages/plugin-linux-webgpu-test-stage.yml b/tools/ci_build/github/azure-pipelines/stages/plugin-linux-webgpu-test-stage.yml
index 9ce494d4b3a36..12ee9ca68bb4e 100644
--- a/tools/ci_build/github/azure-pipelines/stages/plugin-linux-webgpu-test-stage.yml
+++ b/tools/ci_build/github/azure-pipelines/stages/plugin-linux-webgpu-test-stage.yml
@@ -71,7 +71,7 @@ stages:
set -e -x
python3 -m venv /build/test_venv
source /build/test_venv/bin/activate
- python3 -m pip install onnxruntime onnx numpy
+ python3 -m pip install onnx numpy
wheel=\$(find /build/python_wheel -name 'onnxruntime_ep_webgpu-*.whl' | head -1)
python3 -m pip install \"\$wheel\"
python3 -u /onnxruntime_src/plugin-ep-webgpu/python/test/test_webgpu_plugin_ep.py
diff --git a/tools/ci_build/github/azure-pipelines/stages/plugin-mac-webgpu-test-stage.yml b/tools/ci_build/github/azure-pipelines/stages/plugin-mac-webgpu-test-stage.yml
index 5ad4e170b2855..6dca5dd450fd0 100644
--- a/tools/ci_build/github/azure-pipelines/stages/plugin-mac-webgpu-test-stage.yml
+++ b/tools/ci_build/github/azure-pipelines/stages/plugin-mac-webgpu-test-stage.yml
@@ -30,7 +30,7 @@ stages:
set -e -x
python3 -m venv "$(Build.BinariesDirectory)/test_venv"
source "$(Build.BinariesDirectory)/test_venv/bin/activate"
- python3 -m pip install onnxruntime onnx numpy
+ python3 -m pip install onnx numpy
wheel=$(find "$(Pipeline.Workspace)/build/webgpu_plugin_python_macos_arm64" -name "onnxruntime_ep_webgpu-*.whl" | head -1)
python3 -m pip install "$wheel"
python3 -u "$(Build.SourcesDirectory)/plugin-ep-webgpu/python/test/test_webgpu_plugin_ep.py"
diff --git a/tools/ci_build/github/azure-pipelines/stages/plugin-webgpu-nuget-packaging-stage.yml b/tools/ci_build/github/azure-pipelines/stages/plugin-webgpu-nuget-packaging-stage.yml
new file mode 100644
index 0000000000000..93210533d2dc0
--- /dev/null
+++ b/tools/ci_build/github/azure-pipelines/stages/plugin-webgpu-nuget-packaging-stage.yml
@@ -0,0 +1,186 @@
+# NuGet packaging stage for WebGPU plugin EP.
+# Downloads platform-specific build artifacts, packs them into a single multi-platform NuGet package,
+# signs it, and runs a basic test.
+
+parameters:
+- name: package_version
+ type: string
+
+- name: version_file
+ type: string
+
+- name: DoEsrp
+ type: boolean
+ default: true
+
+- name: platforms
+ type: object
+ default:
+ win_x64: false
+ win_arm64: false
+ linux_x64: false
+ macos_arm64: false
+
+stages:
+- stage: NuGet_Packaging
+ displayName: 'NuGet Packaging'
+ dependsOn:
+ - ${{ if eq(parameters.platforms.win_x64, true) }}:
+ - Win_plugin_webgpu_x64_Build
+ - ${{ if eq(parameters.platforms.win_arm64, true) }}:
+ - Win_plugin_webgpu_arm64_Build
+ - ${{ if eq(parameters.platforms.linux_x64, true) }}:
+ - Linux_plugin_webgpu_x64_Build
+ - ${{ if eq(parameters.platforms.macos_arm64, true) }}:
+ - MacOS_plugin_webgpu_arm64_Build
+ jobs:
+ # ---------- Pack job ----------
+ - job: NuGet_Pack
+ displayName: 'Pack NuGet'
+ timeoutInMinutes: 30
+ workspace:
+ clean: all
+ pool:
+ name: onnxruntime-Win-CPU-VS2022-Latest
+ os: windows
+ templateContext:
+ outputs:
+ - output: pipelineArtifact
+ targetPath: '$(Build.ArtifactStagingDirectory)\nuget'
+ artifactName: webgpu_plugin_nuget
+ variables:
+ - template: ../templates/common-variables.yml
+ - name: WebGpuPackStagingDir
+ value: '$(Build.BinariesDirectory)\webgpu_pack_staging'
+ # Common arguments shared by the Build and Pack invocations of pack_nuget.py.
+ - name: WebGpuPackNuGetCommonArgs
+ value: >-
+ --version "$(PluginPackageVersion)"
+ --output-dir "$(Build.ArtifactStagingDirectory)\nuget"
+ --staging-dir "$(WebGpuPackStagingDir)"
+ --configuration Release
+ --nuget-config "$(Build.SourcesDirectory)\NuGet.config"
+ steps:
+ - checkout: self
+ clean: true
+ submodules: none
+
+ - template: ../templates/setup-build-tools.yml
+ parameters:
+ host_cpu_arch: 'x64'
+
+ - template: ../templates/set-nightly-build-option-variable-step.yml
+
+ - template: ../templates/set-plugin-build-variables-step.yml
+ parameters:
+ package_version: ${{ parameters.package_version }}
+ version_file: ${{ parameters.version_file }}
+
+ # Download platform artifacts
+ - ${{ if eq(parameters.platforms.win_x64, true) }}:
+ - task: DownloadPipelineArtifact@2
+ displayName: 'Download win-x64 artifacts'
+ inputs:
+ artifactName: webgpu_plugin_win_x64
+ targetPath: '$(Build.BinariesDirectory)\artifacts\win_x64'
+
+ - ${{ if eq(parameters.platforms.win_arm64, true) }}:
+ - task: DownloadPipelineArtifact@2
+ displayName: 'Download win-arm64 artifacts'
+ inputs:
+ artifactName: webgpu_plugin_win_arm64
+ targetPath: '$(Build.BinariesDirectory)\artifacts\win_arm64'
+
+ - ${{ if eq(parameters.platforms.linux_x64, true) }}:
+ - task: DownloadPipelineArtifact@2
+ displayName: 'Download linux-x64 artifacts'
+ inputs:
+ artifactName: webgpu_plugin_linux_x64
+ targetPath: '$(Build.BinariesDirectory)\artifacts\linux_x64'
+
+ - ${{ if eq(parameters.platforms.macos_arm64, true) }}:
+ - task: DownloadPipelineArtifact@2
+ displayName: 'Download macos-arm64 artifacts'
+ inputs:
+ artifactName: webgpu_plugin_macos_arm64
+ targetPath: '$(Build.BinariesDirectory)\artifacts\macos_arm64'
+
+ # Compute the set of required platforms from the pipeline parameters and verify the
+ # corresponding artifact directories actually downloaded. This catches renamed/moved
+ # upstream artifacts loudly before any pack work, and feeds pack_nuget.py the same
+ # list so it fails fast if any required platform's binaries are missing.
+ - task: PythonScript@0
+ displayName: 'Compute required platforms'
+ inputs:
+ scriptSource: inline
+ script: |
+ import os
+ import sys
+
+ # The string literals below are filled in by ADO template expansion at queue
+ # time and resolve to a boolean value 'True' or 'False'. Compare case-insensitively.
+ platforms_enabled = {
+ "win_x64": "${{ parameters.platforms.win_x64 }}".lower() == "true",
+ "win_arm64": "${{ parameters.platforms.win_arm64 }}".lower() == "true",
+ "linux_x64": "${{ parameters.platforms.linux_x64 }}".lower() == "true",
+ "macos_arm64": "${{ parameters.platforms.macos_arm64 }}".lower() == "true",
+ }
+ expected = [name for name, enabled in platforms_enabled.items() if enabled]
+
+ if not expected:
+ print("##vso[task.logissue type=error]No platforms enabled in 'platforms' parameter — nothing to pack.")
+ sys.exit(1)
+
+ artifacts_dir = r"$(Build.BinariesDirectory)\artifacts"
+ missing = [
+ f"{p} ({d})"
+ for p in expected
+ for d in [os.path.join(artifacts_dir, p, "bin")]
+ if not os.path.isdir(d)
+ ]
+ if missing:
+ print("##vso[task.logissue type=error]Expected artifact directories not found:")
+ for m in missing:
+ print(f"##vso[task.logissue type=error] {m}")
+ sys.exit(1)
+
+ required = ",".join(expected)
+ print(f"Required platforms: {required}")
+ print(f"##vso[task.setvariable variable=WebGpuRequiredPlatforms]{required}")
+
+ # Stage binaries and build the managed assembly (so it can be ESRP-signed before packing).
+ - task: PythonScript@0
+ displayName: 'Build managed DLL'
+ inputs:
+ scriptSource: filePath
+ scriptPath: '$(Build.SourcesDirectory)\plugin-ep-webgpu\csharp\pack_nuget.py'
+ arguments: >-
+ $(WebGpuPackNuGetCommonArgs)
+ --artifacts-dir "$(Build.BinariesDirectory)\artifacts"
+ --required-platforms $(WebGpuRequiredPlatforms)
+ --build-only
+
+ # ESRP-sign the managed DLL before it gets embedded in the .nupkg.
+ - template: ../templates/win-esrp-dll.yml
+ parameters:
+ FolderPath: '$(WebGpuPackStagingDir)'
+ Pattern: 'Microsoft.ML.OnnxRuntime.EP.WebGpu.dll'
+ DisplayName: 'ESRP - Sign managed DLL'
+ DoEsrp: ${{ parameters.DoEsrp }}
+
+ # Pack the (now-signed) managed DLL plus native binaries into the .nupkg.
+ - task: PythonScript@0
+ displayName: 'Pack NuGet package'
+ inputs:
+ scriptSource: filePath
+ scriptPath: '$(Build.SourcesDirectory)\plugin-ep-webgpu\csharp\pack_nuget.py'
+ arguments: >-
+ $(WebGpuPackNuGetCommonArgs)
+ --pack-only
+
+ # ESRP sign
+ - template: ../templates/esrp_nuget.yml
+ parameters:
+ FolderPath: '$(Build.ArtifactStagingDirectory)\nuget'
+ DisplayName: 'ESRP - Sign NuGet package'
+ DoEsrp: ${{ parameters.DoEsrp }}
diff --git a/tools/ci_build/github/azure-pipelines/stages/plugin-webgpu-packaging-stage.yml b/tools/ci_build/github/azure-pipelines/stages/plugin-webgpu-packaging-stage.yml
index 6777f207d67b9..996d6fa1af0a6 100644
--- a/tools/ci_build/github/azure-pipelines/stages/plugin-webgpu-packaging-stage.yml
+++ b/tools/ci_build/github/azure-pipelines/stages/plugin-webgpu-packaging-stage.yml
@@ -48,9 +48,7 @@ stages:
cmake_build_type: ${{ parameters.cmake_build_type }}
# Windows ARM64
- # ARM64 build requires the x64 tblgen.exe (used during the build), which is not correctly
- # generated in a cross build. So we require x64 to be built first and download tblgen.exe from it.
- - ${{ if and(eq(parameters.build_windows_arm64, true), eq(parameters.build_windows_x64, true)) }}:
+ - ${{ if eq(parameters.build_windows_arm64, true) }}:
- template: plugin-win-webgpu-stage.yml
parameters:
arch: 'arm64'
@@ -74,13 +72,25 @@ stages:
version_file: ${{ parameters.version_file }}
cmake_build_type: ${{ parameters.cmake_build_type }}
+ # NuGet packaging (runs after all platform builds)
+ - template: plugin-webgpu-nuget-packaging-stage.yml
+ parameters:
+ package_version: ${{ parameters.package_version }}
+ version_file: ${{ parameters.version_file }}
+ DoEsrp: true
+ platforms:
+ win_x64: ${{ parameters.build_windows_x64 }}
+ win_arm64: ${{ parameters.build_windows_arm64 }}
+ linux_x64: ${{ parameters.build_linux_x64 }}
+ macos_arm64: ${{ parameters.build_macos_arm64 }}
+
# Create zip packages for Foundry Local consumption
- stage: Package_Foundry_Local_WebGPU_Zips
displayName: 'Package Foundry Local WebGPU Plugin-EP Zips'
dependsOn:
- ${{ if eq(parameters.build_windows_x64, true) }}:
- Win_plugin_webgpu_x64_Build
- - ${{ if and(eq(parameters.build_windows_arm64, true), eq(parameters.build_windows_x64, true)) }}:
+ - ${{ if eq(parameters.build_windows_arm64, true) }}:
- Win_plugin_webgpu_arm64_Build
- ${{ if eq(parameters.build_linux_x64, true) }}:
- Linux_plugin_webgpu_x64_Build
@@ -111,10 +121,7 @@ stages:
artifactName: webgpu_plugin_win_x64
targetPath: $(Build.SourcesDirectory)/webgpu-plugin-win-x64
- # Windows ARM64
- # ARM64 build requires the x64 tblgen.exe (used during the build), which is not correctly
- # generated in a cross build. So we require x64 to be built first and download tblgen.exe from it.
- - ${{ if and(eq(parameters.build_windows_arm64, true), eq(parameters.build_windows_x64, true)) }}:
+ - ${{ if eq(parameters.build_windows_arm64, true) }}:
- task: DownloadPipelineArtifact@2
displayName: 'Download webgpu_plugin_win_arm64'
inputs:
diff --git a/tools/ci_build/github/azure-pipelines/stages/plugin-win-webgpu-stage.yml b/tools/ci_build/github/azure-pipelines/stages/plugin-win-webgpu-stage.yml
index acad674143961..3d2ce8072845a 100644
--- a/tools/ci_build/github/azure-pipelines/stages/plugin-win-webgpu-stage.yml
+++ b/tools/ci_build/github/azure-pipelines/stages/plugin-win-webgpu-stage.yml
@@ -28,6 +28,7 @@ parameters:
stages:
- stage: Win_plugin_webgpu_${{ parameters.arch }}_Build
${{ if eq(parameters.arch, 'arm64') }}:
+ # The ARM64 build consumes the x64 tblgen.exe artifact published by the Windows x64 stage.
dependsOn: Win_plugin_webgpu_x64_Build
${{ else }}:
dependsOn: []
diff --git a/tools/ci_build/github/azure-pipelines/stages/plugin-win-webgpu-test-stage.yml b/tools/ci_build/github/azure-pipelines/stages/plugin-win-webgpu-test-stage.yml
index af29a62d69329..1494584ff98fd 100644
--- a/tools/ci_build/github/azure-pipelines/stages/plugin-win-webgpu-test-stage.yml
+++ b/tools/ci_build/github/azure-pipelines/stages/plugin-win-webgpu-test-stage.yml
@@ -31,29 +31,103 @@ stages:
artifact: webgpu_plugin_python_win_${{ parameters.arch }}
displayName: 'Download Python wheel'
- - task: PowerShell@2
+ - pwsh: |
+ $ErrorActionPreference = 'Stop'
+
+ echo "creating test_venv"
+ python -m venv "$(Build.BinariesDirectory)\test_venv"
+
+ echo "activating test_venv"
+ & "$(Build.BinariesDirectory)\test_venv\Scripts\Activate.ps1"
+
+ echo "installing test dependencies"
+ python -m pip install onnx numpy
+
+ $wheelDir = "$(Pipeline.Workspace)\build\webgpu_plugin_python_win_${{ parameters.arch }}"
+ $wheel = (Get-ChildItem "$wheelDir\onnxruntime_ep_webgpu-*.whl")[0]
+ echo "installing ${wheel}"
+ python -m pip install $wheel.FullName
+
+ echo "running test_webgpu_plugin_ep.py"
+ python -u "$(Build.SourcesDirectory)\plugin-ep-webgpu\python\test\test_webgpu_plugin_ep.py"
displayName: 'Install and test Python package'
env:
ORT_TEST_VERBOSE: $(System.Debug)
- inputs:
- targetType: inline
- pwsh: true
- script: |
+
+ # NuGet package test (x64 only — the NuGet package is multi-platform but
+ # the test runs on a single Windows agent that exercises the WebGPU EP).
+ - ${{ if eq(parameters.arch, 'x64') }}:
+ - job: Win_plugin_webgpu_nuget_Test
+ timeoutInMinutes: 30
+ workspace:
+ clean: all
+ pool:
+ name: onnxruntime-Win2022-VS2022-webgpu-A10
+ os: windows
+ variables:
+ WebGpuTestProject: '$(Build.SourcesDirectory)\plugin-ep-webgpu\csharp\test\WebGpuEpNuGetTest\WebGpuEpNuGetTest.csproj'
+ steps:
+ - checkout: self
+ submodules: none
+
+ - template: ../templates/setup-feeds-and-python-steps.yml
+
+ # Download the NuGet package produced by the packaging pipeline run that
+ # triggered this pipeline (or that was selected at queue time).
+ - download: build
+ artifact: webgpu_plugin_nuget
+ displayName: 'Download NuGet package'
+
+ # Set up local NuGet feed and extract the package version from the .nupkg filename
+ # so the test project can pin to it (instead of resolving via a floating version).
+ - pwsh: |
$ErrorActionPreference = 'Stop'
+ $localFeedDir = "$(Build.BinariesDirectory)\local_feed"
+ New-Item -ItemType Directory -Path $localFeedDir -Force | Out-Null
- echo "creating test_venv"
- python -m venv "$(Build.BinariesDirectory)\test_venv"
+ # Locate the .nupkg.
+ $nupkg = Get-ChildItem "$(Pipeline.Workspace)\build\webgpu_plugin_nuget\Microsoft.ML.OnnxRuntime.EP.WebGpu.*.nupkg" |
+ Select-Object -First 1
+ if (-not $nupkg) {
+ throw "No matching .nupkg found under $(Pipeline.Workspace)\build\webgpu_plugin_nuget"
+ }
+ Copy-Item $nupkg.FullName $localFeedDir -Force
- echo "activating test_venv"
- & "$(Build.BinariesDirectory)\test_venv\Scripts\Activate.ps1"
+ # Extract version from filename: Microsoft.ML.OnnxRuntime.EP.WebGpu..nupkg
+ # The version starts with a digit, which disambiguates from any future filename suffixes.
+ if ($nupkg.BaseName -notmatch '^Microsoft\.ML\.OnnxRuntime\.EP\.WebGpu\.(\d.*)$') {
+ throw "Could not extract version from .nupkg filename: $($nupkg.Name)"
+ }
+ $packageVersion = $Matches[1]
+ Write-Host "Detected package version: $packageVersion"
+ Write-Host "##vso[task.setvariable variable=OrtWebGpuPackageVersion]$packageVersion"
- echo "installing onnxruntime onnx numpy"
- python -m pip install onnxruntime onnx numpy
+ # Write a project-level nuget.config that adds ONLY the local feed.
+ # NuGet merges this with the repo-root NuGet.config.
+ $nugetConfig = "$(Build.SourcesDirectory)\plugin-ep-webgpu\csharp\test\WebGpuEpNuGetTest\nuget.config"
+ Set-Content -Path $nugetConfig -Encoding UTF8 -Value @"
+
+
+
+
+
+
+ "@
+ Write-Host "Wrote project-level nuget.config with local feed: $localFeedDir"
+ Write-Host "Local feed contents:"
+ Get-ChildItem $localFeedDir | ForEach-Object { Write-Host " $($_.Name)" }
+ displayName: 'Set up local NuGet feed'
- $wheelDir = "$(Pipeline.Workspace)\build\webgpu_plugin_python_win_${{ parameters.arch }}"
- $wheel = (Get-ChildItem "$wheelDir\onnxruntime_ep_webgpu-*.whl")[0]
- echo "installing ${wheel}"
- python -m pip install $wheel.FullName
+ - pwsh: |
+ dotnet build `
+ "$(WebGpuTestProject)" `
+ --configuration Release `
+ -p:OrtWebGpuPackageVersion=$(OrtWebGpuPackageVersion)
+ displayName: 'Build test project'
- echo "running test_webgpu_plugin_ep.py"
- python -u "$(Build.SourcesDirectory)\plugin-ep-webgpu\python\test\test_webgpu_plugin_ep.py"
+ - pwsh: |
+ dotnet run `
+ --project "$(WebGpuTestProject)" `
+ --configuration Release `
+ --no-build
+ displayName: 'Run NuGet package test'