diff --git a/azure-pipelines-pr.yml b/azure-pipelines-pr.yml
index 7ca86bef5c4..9400911b858 100644
--- a/azure-pipelines-pr.yml
+++ b/azure-pipelines-pr.yml
@@ -165,8 +165,7 @@ stages:
inputs:
filePath: eng\common\sdk-task.ps1
arguments: -task PublishToSymbolServers /p:DryRun="true" -restore -msbuildEngine dotnet
- /p:DotNetSymbolServerTokenMsdl=DryRunPTA
- /p:DotNetSymbolServerTokenSymWeb=DryRunPTA
+ /p:DotNetSymbolServerTokenStaging=DryRunPAT
/p:PDBArtifactsDirectory='$(Build.ArtifactStagingDirectory)/PDBArtifacts/'
/p:BlobBasePath='$(Build.ArtifactStagingDirectory)/BlobArtifacts/'
/p:SymbolPublishingExclusionsFile='$(System.DefaultWorkingDirectory)/eng/SymbolPublishingExclusionsFile.txt'
diff --git a/src/Microsoft.DotNet.Arcade.Sdk/toolset/PublishToSymbolServers.proj b/src/Microsoft.DotNet.Arcade.Sdk/toolset/PublishToSymbolServers.proj
index ae826422510..47284a4e8b1 100644
--- a/src/Microsoft.DotNet.Arcade.Sdk/toolset/PublishToSymbolServers.proj
+++ b/src/Microsoft.DotNet.Arcade.Sdk/toolset/PublishToSymbolServers.proj
@@ -4,27 +4,34 @@
+ $(BundledNETCoreAppTargetFramework)
Publish
-
-
-
-
@@ -84,26 +91,21 @@
Text="Going to ignore this file -> %(PackageExcludeFiles.Identity)." />
-
-
- $(DotNetSymbolServerTokenMsdl)
-
-
-
- $(DotNetSymbolServerTokenSymWeb)
-
+
-
-
+
+
+
+
+
diff --git a/src/Microsoft.DotNet.Build.Tasks.Feed/src/PublishSymbolsUsingSymbolUploadHelper.cs b/src/Microsoft.DotNet.Build.Tasks.Feed/src/PublishSymbolsUsingSymbolUploadHelper.cs
new file mode 100644
index 00000000000..2bb6744bc04
--- /dev/null
+++ b/src/Microsoft.DotNet.Build.Tasks.Feed/src/PublishSymbolsUsingSymbolUploadHelper.cs
@@ -0,0 +1,323 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Collections.Generic;
+using System.Collections.Frozen;
+using System.Linq;
+using System.Threading.Tasks;
+using Azure.Core;
+using Microsoft.Build.Framework;
+using Microsoft.DotNet.ArcadeAzureIntegration;
+using Microsoft.DotNet.Internal.SymbolHelper;
+using MsBuildUtils = Microsoft.Build.Utilities;
+
+namespace Microsoft.DotNet.Build.Tasks.Feed;
+
+///
+/// MSBuild task that publishes symbols to symbol servers using the internal SymbolUploadHelper
+/// infrastructure. Symbols are first staged in a temporary Azure DevOps org (default: "dnceng"),
+/// then promoted to the target symbol servers (internal/public) via the SymbolRequest service.
+/// Supports both PAT-based auth (legacy) and Entra/managed identity auth.
+///
+/// Note: Unlike the old PublishSymbols task (Microsoft.SymbolUploader.Build.Task), this task
+/// does not validate that individual files are indexable during dry runs. The 1ES symbol
+/// publishing tool now manages that validation. Dry runs will still decompose packages
+/// and exercise the request lifecycle but skip per-file indexability checks.
+///
+public class PublishSymbolsUsingSymbolUploadHelper : MsBuildUtils.Task
+{
+ ///
+ /// The temporary/staging Azure DevOps organization where symbols are uploaded before promotion.
+ /// Default is "dnceng". Symbols cannot be published directly to the target orgs
+ /// (microsoftpublicsymbols/microsoft); they must be staged here first.
+ ///
+ public string StagingAzdoOrg { get; set; } = "dnceng";
+
+ ///
+ /// The project name used for the SymbolRequest promotion service registration.
+ /// Required for non-dry-run publishing.
+ ///
+ public string SymbolRequestProject { get; set; }
+
+ ///
+ /// Optional personal access token for staging org auth. If provided, PAT-based auth is used.
+ /// If empty or not set, DefaultIdentityTokenCredential (Entra/MI) is used.
+ ///
+ public string PersonalAccessToken { get; set; }
+
+ ///
+ /// Optional managed identity client ID for DefaultIdentityTokenCredential.
+ /// Used for both staging upload and promotion service auth.
+ ///
+ public string ManagedIdentityClientId { get; set; }
+
+ ///
+ /// Whether to publish symbols to the internal symbol server (SymWeb/microsoft org).
+ /// Default is true.
+ ///
+ public bool PublishToInternalServer { get; set; } = true;
+
+ ///
+ /// Whether to publish symbols to the public symbol server (MSDL/microsoftpublicsymbols org).
+ /// Default is true.
+ ///
+ public bool PublishToPublicServer { get; set; } = true;
+
+ ///
+ /// Symbol packages (*.symbols.nupkg) to publish.
+ ///
+ public ITaskItem[] PackagesToPublish { get; set; }
+
+ ///
+ /// Individual files (PDBs, DLLs, etc.) to publish.
+ ///
+ public ITaskItem[] FilesToPublish { get; set; }
+
+ ///
+ /// Files to exclude from symbol packages during publishing.
+ ///
+ public ITaskItem[] PackageExcludeFiles { get; set; }
+
+ ///
+ /// Number of days before the symbol request expires. Default is 3650.
+ ///
+ public int ExpirationInDays { get; set; } = 3650;
+
+ ///
+ /// Whether to enable verbose logging from the symbol client.
+ ///
+ public bool VerboseLogging { get; set; }
+
+ ///
+ /// Whether to perform a dry run without actually publishing.
+ ///
+ public bool DryRun { get; set; }
+
+ ///
+ /// Whether to convert portable PDBs to Windows PDBs.
+ ///
+ public bool ConvertPortablePdbsToWindowsPdbs { get; set; }
+
+ ///
+ /// Whether to treat PDB conversion issues as informational messages.
+ ///
+ public bool TreatPdbConversionIssuesAsInfo { get; set; }
+
+ ///
+ /// Comma-separated list of PDB conversion diagnostic IDs to treat as warnings.
+ ///
+ public string PdbConversionTreatAsWarning { get; set; }
+
+ ///
+ /// Whether to publish special CLR files (DAC, DBI, SOS) under diagnostic indexes.
+ ///
+ public bool PublishSpecialClrFiles { get; set; }
+
+ ///
+ /// Directory containing loose PDB/DLL files to publish.
+ /// If set, files from this directory are added via AddDirectory instead of individual file items.
+ ///
+ public string PDBArtifactsDirectory { get; set; }
+
+ public override bool Execute()
+ {
+ return ExecuteAsync().GetAwaiter().GetResult();
+ }
+
+ private async Task ExecuteAsync()
+ {
+ try
+ {
+ if (!PublishToInternalServer && !PublishToPublicServer)
+ {
+ Log.LogMessage(MessageImportance.High, "Neither internal nor public symbol server publishing is requested. Skipping.");
+ return true;
+ }
+
+ TokenCredential stagingCredential = CreateStagingCredential();
+ TokenCredential promotionCredential = CreatePromotionCredential();
+ TaskTracer tracer = new(Log, verbose: VerboseLogging);
+
+ IEnumerable pdbWarnings = ParsePdbConversionWarnings();
+ FrozenSet exclusions = PackageExcludeFiles?.Select(i => i.ItemSpec).ToFrozenSet()
+ ?? FrozenSet.Empty;
+
+ SymbolPublisherOptions options = new(
+ StagingAzdoOrg,
+ stagingCredential,
+ packageFileExcludeList: exclusions,
+ convertPortablePdbs: ConvertPortablePdbsToWindowsPdbs,
+ treatPdbConversionIssuesAsInfo: TreatPdbConversionIssuesAsInfo,
+ pdbConversionTreatAsWarning: pdbWarnings,
+ dotnetInternalPublishSpecialClrFiles: PublishSpecialClrFiles,
+ verboseClient: VerboseLogging,
+ isDryRun: DryRun);
+
+ SymbolUploadHelper helper = DryRun
+ ? SymbolUploadHelperFactory.GetSymbolHelperFromLocalTool(tracer, options, ".")
+ : await SymbolUploadHelperFactory.GetSymbolHelperWithDownloadAsync(tracer, options);
+
+ string requestName = $"arcade-sdk/{StagingAzdoOrg}/{Guid.NewGuid()}";
+ Log.LogMessage(MessageImportance.High, "Creating symbol request '{0}' in staging org '{1}'", requestName, StagingAzdoOrg);
+
+ int result = await helper.CreateRequest(requestName);
+ if (result != 0)
+ {
+ Log.LogError("Failed to create symbol request '{0}'. Exit code: {1}", requestName, result);
+ return false;
+ }
+
+ bool uploadSucceeded = false;
+ try
+ {
+ // Add loose files directory if specified
+ if (!string.IsNullOrEmpty(PDBArtifactsDirectory))
+ {
+ Log.LogMessage(MessageImportance.High, "Adding directory '{0}' to symbol request", PDBArtifactsDirectory);
+ result = await helper.AddDirectory(requestName, PDBArtifactsDirectory);
+ if (result != 0)
+ {
+ Log.LogError("Failed to add directory to symbol request. Exit code: {0}", result);
+ return false;
+ }
+ }
+
+ // Add individual file items if specified (and no directory was given)
+ if (FilesToPublish?.Length > 0 && string.IsNullOrEmpty(PDBArtifactsDirectory))
+ {
+ // SymbolUploadHelper works with directories, so we need to group files
+ // by their parent directory and add each directory.
+ var directories = FilesToPublish
+ .Select(f => System.IO.Path.GetDirectoryName(f.ItemSpec))
+ .Where(d => !string.IsNullOrEmpty(d))
+ .Distinct(StringComparer.OrdinalIgnoreCase);
+
+ foreach (string dir in directories)
+ {
+ Log.LogMessage(MessageImportance.Normal, "Adding file directory '{0}' to symbol request", dir);
+ result = await helper.AddDirectory(requestName, dir);
+ if (result != 0)
+ {
+ Log.LogError("Failed to add directory '{0}' to symbol request. Exit code: {1}", dir, result);
+ return false;
+ }
+ }
+ }
+
+ // Add symbol packages
+ if (PackagesToPublish?.Length > 0)
+ {
+ IEnumerable packagePaths = PackagesToPublish.Select(p => p.ItemSpec);
+ Log.LogMessage(MessageImportance.High, "Adding {0} symbol package(s) to request", PackagesToPublish.Length);
+
+ result = await helper.AddPackagesToRequest(requestName, packagePaths);
+ if (result != 0)
+ {
+ Log.LogError("Failed to add packages to symbol request. Exit code: {0}", result);
+ return false;
+ }
+ }
+
+ Log.LogMessage(MessageImportance.High, "Finalizing symbol request in staging org with expiration of {0} days", ExpirationInDays);
+ result = await helper.FinalizeRequest(requestName, (uint)ExpirationInDays);
+ if (result != 0)
+ {
+ Log.LogError("Failed to finalize symbol request. Exit code: {0}", result);
+ return false;
+ }
+
+ uploadSucceeded = true;
+ }
+ finally
+ {
+ if (!uploadSucceeded)
+ {
+ Log.LogMessage(MessageImportance.High, "Symbol upload failed. Deleting request '{0}'.", requestName);
+ await helper.DeleteRequest(requestName);
+ }
+ }
+
+ // Promote the finalized request to the target symbol servers
+ SymbolPromotionHelper.Visibility visibility = PublishToPublicServer
+ ? SymbolPromotionHelper.Visibility.Public
+ : SymbolPromotionHelper.Visibility.Internal;
+
+ if (DryRun)
+ {
+ Log.LogMessage(MessageImportance.High,
+ "Dry run: would register request '{0}' to project '{1}' with visibility '{2}' for {3} days.",
+ requestName, SymbolRequestProject, visibility, ExpirationInDays);
+ }
+ else
+ {
+ if (string.IsNullOrEmpty(SymbolRequestProject))
+ {
+ Log.LogError("SymbolRequestProject is required for non-dry-run symbol publishing promotion.");
+ return false;
+ }
+
+ Log.LogMessage(MessageImportance.High,
+ "Promoting symbol request '{0}' to project '{1}' with visibility '{2}'",
+ requestName, SymbolRequestProject, visibility);
+
+ if (!await SymbolPromotionHelper.RegisterAndPublishRequest(
+ tracer, promotionCredential, SymbolPromotionHelper.Environment.Prod,
+ SymbolRequestProject, requestName, (uint)ExpirationInDays, visibility))
+ {
+ Log.LogError("Failed to register and promote symbol request to the target symbol servers.");
+ return false;
+ }
+ }
+
+ Log.LogMessage(MessageImportance.High, "Successfully published and promoted symbols (visibility: {0})", visibility);
+ return true;
+ }
+ catch (Exception ex)
+ {
+ Log.LogErrorFromException(ex, showStackTrace: true);
+ return false;
+ }
+ }
+
+ private TokenCredential CreateStagingCredential()
+ {
+ if (!string.IsNullOrEmpty(PersonalAccessToken))
+ {
+ Log.LogMessage(MessageImportance.Normal, "Using PAT-based authentication for staging symbol upload");
+ return new PATCredential(PersonalAccessToken);
+ }
+
+ Log.LogMessage(MessageImportance.Normal, "Using Entra/managed identity authentication for staging symbol upload");
+ return CreateDefaultCredential();
+ }
+
+ private TokenCredential CreatePromotionCredential()
+ {
+ Log.LogMessage(MessageImportance.Normal, "Using Entra/managed identity authentication for symbol promotion");
+ return CreateDefaultCredential();
+ }
+
+ private DefaultIdentityTokenCredential CreateDefaultCredential()
+ {
+ var options = new DefaultIdentityTokenCredentialOptions();
+ if (!string.IsNullOrEmpty(ManagedIdentityClientId))
+ {
+ options.ManagedIdentityClientId = ManagedIdentityClientId;
+ }
+ return new DefaultIdentityTokenCredential(options);
+ }
+
+ private IEnumerable ParsePdbConversionWarnings()
+ {
+ if (string.IsNullOrEmpty(PdbConversionTreatAsWarning))
+ {
+ return [];
+ }
+
+ return PdbConversionTreatAsWarning
+ .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+ .Where(s => int.TryParse(s, out _))
+ .Select(int.Parse);
+ }
+}