From f8a9a268c598357f4eb9db710e028e09d3ff98c4 Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Mon, 17 Aug 2026 14:18:10 -0500 Subject: [PATCH 1/6] Publish filtered signed packages through BAR Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- azure-pipelines.yml | 2 + build/ci/stage-publish-signed-artifacts.yml | 137 ++++++++++++++++++ build/ci/variables.yml | 10 ++ .../FeedFailureClassifierTests.cs | 27 ++++ .../GlobalUsings.cs | 1 + .../PackageInspectorTests.cs | 60 ++++++++ .../PublishingOutputTests.cs | 63 ++++++++ .../PublishingPlannerTests.cs | 134 +++++++++++++++++ .../SignedPackagePublisher.Tests.csproj | 18 +++ .../SignedPackagePublisher/Models.cs | 45 ++++++ .../SignedPackagePublisher/NuGetFeedProbe.cs | 107 ++++++++++++++ .../PackageInspector.cs | 48 ++++++ .../SignedPackagePublisher/Program.cs | 103 +++++++++++++ .../PublishingOutput.cs | 115 +++++++++++++++ .../PublishingPlanner.cs | 123 ++++++++++++++++ .../SignedPackagePublisher.csproj | 20 +++ build/publishing/promote-build.ps1 | 48 ++++++ docs/publishing-signed-packages.md | 19 +++ 18 files changed, 1080 insertions(+) create mode 100644 build/ci/stage-publish-signed-artifacts.yml create mode 100644 build/publishing/SignedPackagePublisher.Tests/FeedFailureClassifierTests.cs create mode 100644 build/publishing/SignedPackagePublisher.Tests/GlobalUsings.cs create mode 100644 build/publishing/SignedPackagePublisher.Tests/PackageInspectorTests.cs create mode 100644 build/publishing/SignedPackagePublisher.Tests/PublishingOutputTests.cs create mode 100644 build/publishing/SignedPackagePublisher.Tests/PublishingPlannerTests.cs create mode 100644 build/publishing/SignedPackagePublisher.Tests/SignedPackagePublisher.Tests.csproj create mode 100644 build/publishing/SignedPackagePublisher/Models.cs create mode 100644 build/publishing/SignedPackagePublisher/NuGetFeedProbe.cs create mode 100644 build/publishing/SignedPackagePublisher/PackageInspector.cs create mode 100644 build/publishing/SignedPackagePublisher/Program.cs create mode 100644 build/publishing/SignedPackagePublisher/PublishingOutput.cs create mode 100644 build/publishing/SignedPackagePublisher/PublishingPlanner.cs create mode 100644 build/publishing/SignedPackagePublisher/SignedPackagePublisher.csproj create mode 100644 build/publishing/promote-build.ps1 create mode 100644 docs/publishing-signed-packages.md diff --git a/azure-pipelines.yml b/azure-pipelines.yml index c971100db..cefa2de7e 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -94,3 +94,5 @@ extends: os: windows - template: build/ci/stage-sign-artifacts.yml@self + + - template: build/ci/stage-publish-signed-artifacts.yml@self diff --git a/build/ci/stage-publish-signed-artifacts.yml b/build/ci/stage-publish-signed-artifacts.yml new file mode 100644 index 000000000..1ba56f885 --- /dev/null +++ b/build/ci/stage-publish-signed-artifacts.yml @@ -0,0 +1,137 @@ +# Filters and publishes only real-signed packages that are absent from the public dotnet10 feed. + +stages: +- stage: publish_signed_packages + displayName: Publish Signed Packages + dependsOn: sign_artifacts + condition: and(succeeded(), eq(variables['barPublishingEnabled'], 'true'), or(and(eq(variables['Build.SourceBranch'], 'refs/heads/main'), ne(variables['Build.Reason'], 'Schedule')), startsWith(variables['Build.SourceBranch'], 'refs/heads/release/')), ne(variables['Build.Reason'], 'PullRequest')) + lockBehavior: sequential + + jobs: + - deployment: publish_signed_packages + displayName: Filter, register, and promote signed packages + timeoutInMinutes: 240 + environment: $(barPublishingEnvironment) + pool: + name: AzurePipelines-EO + image: 1ESPT-Windows2025 + demands: Cmd + os: windows + strategy: + runOnce: + deploy: + steps: + - checkout: self + clean: true + fetchDepth: 3 + + - task: DownloadPipelineArtifact@2 + displayName: Download real-signed NuGet packages + inputs: + artifactName: nuget-signed + targetPath: $(Pipeline.Workspace)\nuget-signed + + - task: UseDotNet@2 + displayName: Install .NET SDK + inputs: + version: 10.x + + - task: NuGetAuthenticate@1 + displayName: Authenticate to Azure Artifacts + + - task: PowerShell@2 + displayName: Filter signed packages and create Arcade V3 manifest + inputs: + targetType: inline + script: | + $ErrorActionPreference = 'Stop' + $outputRoot = '$(Build.ArtifactStagingDirectory)\SignedPackagePublishing' + New-Item -ItemType Directory -Force -Path ` + "$outputRoot\Audit", ` + "$outputRoot\PackageArtifacts", ` + "$outputRoot\AssetManifests" | Out-Null + + dotnet run ` + --project '$(System.DefaultWorkingDirectory)\build\publishing\SignedPackagePublisher\SignedPackagePublisher.csproj' ` + --configuration Release ` + -- ` + --packages '$(Pipeline.Workspace)\nuget-signed' ` + --package-output "$outputRoot\PackageArtifacts" ` + --inventory "$outputRoot\Audit\signed-package-inventory.json" ` + --manifest "$outputRoot\AssetManifests\Manifest.xml" ` + --feed '$(barTargetFeed)' ` + --max-concurrency '$(barFeedQueryConcurrency)' ` + --max-attempts '$(barFeedQueryAttempts)' ` + --repository-name '$(Build.Repository.Name)' ` + --build-number '$(Build.BuildNumber)' ` + --branch '$(Build.SourceBranch)' ` + --commit '$(Build.SourceVersion)' ` + --azure-collection-uri '$(System.CollectionUri)' ` + --azure-project '$(System.TeamProject)' ` + --azure-build-id '$(Build.BuildId)' ` + --azure-definition-id '$(System.DefinitionId)' ` + --azure-repository-uri '$(Build.Repository.Uri)' + if ($LastExitCode -ne 0) { + throw "Signed package filtering failed with exit code $LastExitCode." + } + + - template: /eng/common/templates-official/steps/publish-pipeline-artifacts.yml + parameters: + is1ESPipeline: true + args: + displayName: Publish signed package audit inventory + targetPath: $(Build.ArtifactStagingDirectory)\SignedPackagePublishing\Audit + artifactName: SignedPackagePublishingInventory + condition: always() + isProduction: false + + - template: /eng/common/templates-official/steps/publish-pipeline-artifacts.yml + parameters: + is1ESPipeline: true + args: + displayName: Publish filtered signed packages + targetPath: $(Build.ArtifactStagingDirectory)\SignedPackagePublishing\PackageArtifacts + artifactName: PackageArtifacts + condition: and(succeeded(), ne(variables['IncludedPackageCount'], '0')) + isProduction: true + + - template: /eng/common/templates-official/steps/publish-pipeline-artifacts.yml + parameters: + is1ESPipeline: true + args: + displayName: Publish filtered Arcade asset manifest + targetPath: $(Build.ArtifactStagingDirectory)\SignedPackagePublishing\AssetManifests + artifactName: AssetManifests + condition: and(succeeded(), ne(variables['IncludedPackageCount'], '0')) + isProduction: false + + - task: AzureCLI@2 + displayName: Register filtered signed packages in BAR + condition: and(succeeded(), ne(variables['IncludedPackageCount'], '0')) + inputs: + azureSubscription: "Darc: Maestro Production" + scriptType: ps + scriptLocation: scriptPath + scriptPath: $(System.DefaultWorkingDirectory)\eng\common\sdk-task.ps1 + arguments: > + -task PublishBuildAssets + -restore + -msbuildEngine dotnet + /p:ManifestsPath='$(Build.ArtifactStagingDirectory)\SignedPackagePublishing\AssetManifests' + /p:IsAssetlessBuild=false + /p:MaestroApiEndpoint=https://maestro.dot.net + /p:OfficialBuildId=$(Build.BuildNumber) + + - task: AzureCLI@2 + displayName: Promote BAR build to $(barTargetChannelName) + condition: and(succeeded(), ne(variables['IncludedPackageCount'], '0')) + inputs: + azureSubscription: "Darc: Maestro Production" + scriptType: ps + scriptLocation: scriptPath + scriptPath: $(System.DefaultWorkingDirectory)\build\publishing\promote-build.ps1 + arguments: > + -BuildId $(BARBuildId) + -ChannelName '$(barTargetChannelName)' + -ExpectedChannelId $(barTargetChannelId) + -AzdoToken '$(System.AccessToken)' diff --git a/build/ci/variables.yml b/build/ci/variables.yml index d63f5d1fc..ca7cf3402 100644 --- a/build/ci/variables.yml +++ b/build/ci/variables.yml @@ -39,3 +39,13 @@ variables: # Signing settings TeamName: .NET MAUI + + # Signed package publishing remains disabled until the Azure DevOps environment + # and service connection setup in docs/publishing-signed-packages.md is complete. + barPublishingEnabled: 'false' + barPublishingEnvironment: android-libraries-dotnet10-publishing + barTargetFeed: https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet10/nuget/v3/index.json + barTargetChannelName: .NET 10 + barTargetChannelId: '5172' + barFeedQueryConcurrency: '8' + barFeedQueryAttempts: '4' diff --git a/build/publishing/SignedPackagePublisher.Tests/FeedFailureClassifierTests.cs b/build/publishing/SignedPackagePublisher.Tests/FeedFailureClassifierTests.cs new file mode 100644 index 000000000..e3e6dcc80 --- /dev/null +++ b/build/publishing/SignedPackagePublisher.Tests/FeedFailureClassifierTests.cs @@ -0,0 +1,27 @@ +using System.Net; + +namespace SignedPackagePublisher.Tests; + +public sealed class FeedFailureClassifierTests +{ + [TestCase(HttpStatusCode.Unauthorized, FeedFailureKind.Authentication)] + [TestCase(HttpStatusCode.Forbidden, FeedFailureKind.Authentication)] + [TestCase(HttpStatusCode.RequestTimeout, FeedFailureKind.Transient)] + [TestCase(HttpStatusCode.TooManyRequests, FeedFailureKind.Transient)] + [TestCase(HttpStatusCode.InternalServerError, FeedFailureKind.Transient)] + [TestCase(HttpStatusCode.BadGateway, FeedFailureKind.Transient)] + [TestCase(HttpStatusCode.BadRequest, FeedFailureKind.Unknown)] + [TestCase(HttpStatusCode.NotFound, FeedFailureKind.Unknown)] + public void ClassifiesHttpFailuresStrictly(HttpStatusCode statusCode, FeedFailureKind expected) + => Assert.That(FeedFailureClassifier.Classify(statusCode), Is.EqualTo(expected)); + + [Test] + public void FindsStatusInInnerException() + { + var exception = new InvalidOperationException( + "wrapper", + new HttpRequestException("rate limited", null, HttpStatusCode.TooManyRequests)); + + Assert.That(FeedFailureClassifier.Classify(exception), Is.EqualTo(FeedFailureKind.Transient)); + } +} diff --git a/build/publishing/SignedPackagePublisher.Tests/GlobalUsings.cs b/build/publishing/SignedPackagePublisher.Tests/GlobalUsings.cs new file mode 100644 index 000000000..324456763 --- /dev/null +++ b/build/publishing/SignedPackagePublisher.Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using NUnit.Framework; diff --git a/build/publishing/SignedPackagePublisher.Tests/PackageInspectorTests.cs b/build/publishing/SignedPackagePublisher.Tests/PackageInspectorTests.cs new file mode 100644 index 000000000..e530af428 --- /dev/null +++ b/build/publishing/SignedPackagePublisher.Tests/PackageInspectorTests.cs @@ -0,0 +1,60 @@ +using System.IO.Compression; + +namespace SignedPackagePublisher.Tests; + +public sealed class PackageInspectorTests +{ + [Test] + public async Task ReadsIdentityAndNormalizesVersionFromNuspec() + { + using var directory = new TemporaryDirectory(); + CreatePackage(Path.Combine(directory.Path, "not-the-identity.nupkg"), "Example.Package", "1.2.3.0"); + + IReadOnlyList packages = await PackageInspector.InspectDirectoryAsync( + directory.Path, + CancellationToken.None); + + Assert.Multiple(() => { + Assert.That(packages, Has.Count.EqualTo(1)); + Assert.That(packages[0].Id, Is.EqualTo("Example.Package")); + Assert.That(packages[0].Version.ToNormalizedString(), Is.EqualTo("1.2.3")); + Assert.That(packages[0].Sha256, Has.Length.EqualTo(64)); + }); + } + + internal static void CreatePackage(string path, string id, string version, string content = "content") + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + using ZipArchive archive = ZipFile.Open(path, ZipArchiveMode.Create); + ZipArchiveEntry nuspec = archive.CreateEntry($"{id}.nuspec"); + using (var writer = new StreamWriter(nuspec.Open())) + { + writer.Write($""" + + + + {id} + {version} + Test + Test + + + """); + } + using var contentWriter = new StreamWriter(archive.CreateEntry("content.txt").Open()); + contentWriter.Write(content); + } +} + +internal sealed class TemporaryDirectory : IDisposable +{ + public TemporaryDirectory() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"signed-package-publisher-{Guid.NewGuid():N}"); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public void Dispose() => Directory.Delete(Path, recursive: true); +} diff --git a/build/publishing/SignedPackagePublisher.Tests/PublishingOutputTests.cs b/build/publishing/SignedPackagePublisher.Tests/PublishingOutputTests.cs new file mode 100644 index 000000000..d43d43381 --- /dev/null +++ b/build/publishing/SignedPackagePublisher.Tests/PublishingOutputTests.cs @@ -0,0 +1,63 @@ +using System.Text.Json; + +namespace SignedPackagePublisher.Tests; + +public sealed class PublishingOutputTests +{ + [Test] + public async Task InventoryIsByteForByteDeterministic() + { + using var directory = new TemporaryDirectory(); + var plan = new PublishingPlan( + new[] { + new PackageInventoryEntry("a.nupkg", "a.nupkg", "aa", "A.Package", "1.0.0", PackageDecision.Include, "missing-from-feed"), + }, + Array.Empty()); + string first = Path.Combine(directory.Path, "first.json"); + string second = Path.Combine(directory.Path, "second.json"); + var feed = new Uri("https://example.test/v3/index.json"); + + await PublishingOutput.WriteInventoryAsync(first, feed, plan, CancellationToken.None); + await PublishingOutput.WriteInventoryAsync(second, feed, plan, CancellationToken.None); + + Assert.That(await File.ReadAllBytesAsync(first), Is.EqualTo(await File.ReadAllBytesAsync(second))); + using JsonDocument document = JsonDocument.Parse(await File.ReadAllTextAsync(first)); + Assert.That(document.RootElement.GetProperty("packages").GetArrayLength(), Is.EqualTo(1)); + } + + [Test] + public void ManifestMarksAssetsAsShippingPackageCategory() + { + using var directory = new TemporaryDirectory(); + string manifestPath = Path.Combine(directory.Path, "Manifest.xml"); + var package = new SignedPackage( + "a.nupkg", + "a.nupkg", + "a.nupkg", + "aa", + "A.Package", + NuGet.Versioning.NuGetVersion.Parse("1.0.0")); + + PublishingOutput.WriteManifest( + manifestPath, + new ManifestIdentity( + "dotnet/android-libraries", + "20260817.1", + "refs/heads/main", + new string('a', 40), + "https://dev.azure.com/devdiv/", + "DevDiv", + 123, + 456, + "https://dev.azure.com/devdiv/DevDiv/_git/android-libraries"), + new[] { package }); + + string xml = File.ReadAllText(manifestPath); + Assert.Multiple(() => { + Assert.That(xml, Does.Contain("PublishingVersion=\"3\"")); + Assert.That(xml, Does.Contain("Id=\"A.Package\"")); + Assert.That(xml, Does.Contain("NonShipping=\"False\"")); + Assert.That(xml, Does.Contain("Category=\"Package\"")); + }); + } +} diff --git a/build/publishing/SignedPackagePublisher.Tests/PublishingPlannerTests.cs b/build/publishing/SignedPackagePublisher.Tests/PublishingPlannerTests.cs new file mode 100644 index 000000000..03ea88a33 --- /dev/null +++ b/build/publishing/SignedPackagePublisher.Tests/PublishingPlannerTests.cs @@ -0,0 +1,134 @@ +using NuGet.Versioning; + +namespace SignedPackagePublisher.Tests; + +public sealed class PublishingPlannerTests +{ + [Test] + public async Task FiltersExistingPackagesAndProducesDeterministicInventory() + { + var packages = new[] { + Package("z.nupkg", "Z.Package", "2.0.0", "bb"), + Package("a.nupkg", "A.Package", "1.0.0", "aa"), + }; + var probe = new StubProbe(("A.Package", "1.0.0", true), ("Z.Package", "2.0.0", false)); + + PublishingPlan plan = await new PublishingPlanner(probe, 2).CreateAsync(packages, CancellationToken.None); + + Assert.Multiple(() => { + Assert.That(plan.Inventory.Select(entry => entry.Id), Is.EqualTo(new[] { "A.Package", "Z.Package" })); + Assert.That(plan.Inventory.Select(entry => entry.Reason), Is.EqualTo(new[] { "already-exists", "missing-from-feed" })); + Assert.That(plan.IncludedPackages.Select(package => package.Id), Is.EqualTo(new[] { "Z.Package" })); + }); + } + + [Test] + public async Task DeduplicatesIdenticalIdentityAndContent() + { + var packages = new[] { + Package("b/copy.nupkg", "Example.Package", "1.0.0", "aa"), + Package("a/original.nupkg", "Example.Package", "1.0.0", "aa"), + }; + var probe = new StubProbe(("Example.Package", "1.0.0", false)); + + PublishingPlan plan = await new PublishingPlanner(probe, 1).CreateAsync(packages, CancellationToken.None); + + Assert.Multiple(() => { + Assert.That(plan.HasErrors, Is.False); + Assert.That(plan.IncludedPackages.Single().RelativePath, Is.EqualTo("a/original.nupkg")); + Assert.That(plan.Inventory.Single(entry => entry.SourceFile == "b/copy.nupkg").Reason, Is.EqualTo("duplicate-identical")); + }); + } + + [Test] + public async Task RejectsDuplicateIdentityWithDifferentContent() + { + var packages = new[] { + Package("a.nupkg", "Example.Package", "1.0.0", "aa"), + Package("b.nupkg", "Example.Package", "1.0.0", "bb"), + }; + + PublishingPlan plan = await new PublishingPlanner(new StubProbe(), 1) + .CreateAsync(packages, CancellationToken.None); + + Assert.Multiple(() => { + Assert.That(plan.HasErrors, Is.True); + Assert.That(plan.Inventory, Has.All.Property(nameof(PackageInventoryEntry.Reason)).EqualTo("duplicate-identity-different-content")); + }); + } + + [Test] + public async Task RejectsDuplicateFilenameWithDifferentIdentities() + { + var packages = new[] { + Package("a/same.nupkg", "First.Package", "1.0.0", "aa"), + Package("b/same.nupkg", "Second.Package", "1.0.0", "bb"), + }; + + PublishingPlan plan = await new PublishingPlanner(new StubProbe(), 1) + .CreateAsync(packages, CancellationToken.None); + + Assert.That(plan.Inventory, Has.All.Property(nameof(PackageInventoryEntry.Reason)).EqualTo("duplicate-filename-different-identity")); + } + + [Test] + public async Task AuditsIdentityDuplicatesRelatedToFilenameCollision() + { + var packages = new[] { + Package("a/same.nupkg", "First.Package", "1.0.0", "aa"), + Package("b/same.nupkg", "Second.Package", "1.0.0", "bb"), + Package("c/other.nupkg", "First.Package", "1.0.0", "aa"), + }; + + PublishingPlan plan = await new PublishingPlanner(new StubProbe(), 1) + .CreateAsync(packages, CancellationToken.None); + + Assert.Multiple(() => { + Assert.That(plan.Inventory, Has.Count.EqualTo(3)); + Assert.That( + plan.Inventory.Single(entry => entry.SourceFile == "c/other.nupkg").Reason, + Is.EqualTo("duplicate-identity-in-conflicting-filename-set")); + }); + } + + [Test] + public async Task TreatsFeedFailuresAsErrorsNotExistingPackages() + { + var package = Package("a.nupkg", "Example.Package", "1.0.0", "aa"); + var probe = new StubProbe(new FeedQueryException("auth failed", new UnauthorizedAccessException())); + + PublishingPlan plan = await new PublishingPlanner(probe, 1) + .CreateAsync(new[] { package }, CancellationToken.None); + + Assert.Multiple(() => { + Assert.That(plan.HasErrors, Is.True); + Assert.That(plan.Inventory.Single().Decision, Is.EqualTo(PackageDecision.Error)); + Assert.That(plan.Inventory.Single().Reason, Does.StartWith("feed-query-failed:")); + }); + } + + private static SignedPackage Package(string relativePath, string id, string version, string hash) + => new(relativePath, relativePath, Path.GetFileName(relativePath), hash, id, NuGetVersion.Parse(version)); + + private sealed class StubProbe : IPackageFeedProbe + { + private readonly Dictionary<(string Id, string Version), bool> results; + private readonly Exception? exception; + + public StubProbe(params (string Id, string Version, bool Exists)[] results) + => this.results = results.ToDictionary( + result => (result.Id, result.Version), + result => result.Exists); + + public StubProbe(Exception exception) + { + results = []; + this.exception = exception; + } + + public Task ExistsAsync(string id, NuGetVersion version, CancellationToken cancellationToken) + => exception is null + ? Task.FromResult(results[(id, version.ToNormalizedString())]) + : Task.FromException(exception); + } +} diff --git a/build/publishing/SignedPackagePublisher.Tests/SignedPackagePublisher.Tests.csproj b/build/publishing/SignedPackagePublisher.Tests/SignedPackagePublisher.Tests.csproj new file mode 100644 index 000000000..ce9ae6a1d --- /dev/null +++ b/build/publishing/SignedPackagePublisher.Tests/SignedPackagePublisher.Tests.csproj @@ -0,0 +1,18 @@ + + + net10.0 + enable + enable + true + false + true + https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-eng/nuget/v3/index.json + + + + + + + + + diff --git a/build/publishing/SignedPackagePublisher/Models.cs b/build/publishing/SignedPackagePublisher/Models.cs new file mode 100644 index 000000000..93287d934 --- /dev/null +++ b/build/publishing/SignedPackagePublisher/Models.cs @@ -0,0 +1,45 @@ +using NuGet.Versioning; + +namespace SignedPackagePublisher; + +public enum PackageDecision +{ + Include, + Exclude, + Error, +} + +public sealed record SignedPackage( + string SourcePath, + string RelativePath, + string FileName, + string Sha256, + string Id, + NuGetVersion Version); + +public sealed record PackageInventoryEntry( + string SourceFile, + string FileName, + string Sha256, + string Id, + string Version, + PackageDecision Decision, + string Reason, + string? CanonicalSourceFile = null); + +public sealed record PublishingPlan( + IReadOnlyList Inventory, + IReadOnlyList IncludedPackages) +{ + public bool HasErrors => Inventory.Any(entry => entry.Decision == PackageDecision.Error); +} + +public enum FeedFailureKind +{ + Transient, + Authentication, + Unknown, +} + +public sealed class FeedQueryException(string message, Exception innerException) + : Exception(message, innerException); diff --git a/build/publishing/SignedPackagePublisher/NuGetFeedProbe.cs b/build/publishing/SignedPackagePublisher/NuGetFeedProbe.cs new file mode 100644 index 000000000..3416679e1 --- /dev/null +++ b/build/publishing/SignedPackagePublisher/NuGetFeedProbe.cs @@ -0,0 +1,107 @@ +using System.Net; +using NuGet.Common; +using NuGet.Configuration; +using NuGet.Protocol; +using NuGet.Protocol.Core.Types; + +namespace SignedPackagePublisher; + +public interface IPackageFeedProbe +{ + Task ExistsAsync(string id, NuGet.Versioning.NuGetVersion version, CancellationToken cancellationToken); +} + +public sealed class NuGetFeedProbe : IPackageFeedProbe, IDisposable +{ + private readonly SourceRepository repository; + private readonly SourceCacheContext cacheContext = new() { + NoCache = true, + DirectDownload = true, + }; + private readonly int maxAttempts; + + public NuGetFeedProbe(Uri serviceIndex, string? token, int maxAttempts) + { + if (!serviceIndex.IsAbsoluteUri) + throw new ArgumentException("The NuGet service index must be an absolute URI.", nameof(serviceIndex)); + if (maxAttempts < 1) + throw new ArgumentOutOfRangeException(nameof(maxAttempts)); + + var packageSource = new PackageSource(serviceIndex.AbsoluteUri); + if (!string.IsNullOrWhiteSpace(token)) + { + packageSource.Credentials = PackageSourceCredential.FromUserInput( + serviceIndex.AbsoluteUri, + "AzureDevOps", + token, + storePasswordInClearText: true, + validAuthenticationTypesText: "basic"); + } + + repository = Repository.Factory.GetCoreV3(packageSource); + this.maxAttempts = maxAttempts; + } + + public async Task ExistsAsync( + string id, + NuGet.Versioning.NuGetVersion version, + CancellationToken cancellationToken) + { + for (int attempt = 1; ; attempt++) + { + try + { + FindPackageByIdResource resource = await repository.GetResourceAsync(cancellationToken); + return await resource.DoesPackageExistAsync(id, version, cacheContext, NullLogger.Instance, cancellationToken); + } + catch (Exception exception) when (attempt < maxAttempts + && FeedFailureClassifier.Classify(exception) == FeedFailureKind.Transient) + { + await Task.Delay(Backoff(attempt), cancellationToken); + } + catch (Exception exception) when (exception is not OperationCanceledException + || !cancellationToken.IsCancellationRequested) + { + FeedFailureKind kind = FeedFailureClassifier.Classify(exception); + throw new FeedQueryException( + $"NuGet feed lookup failed for '{id} {version.ToNormalizedString()}' ({kind}).", + exception); + } + } + } + + public void Dispose() => cacheContext.Dispose(); + + private static TimeSpan Backoff(int attempt) + => TimeSpan.FromMilliseconds(Math.Min(5_000, 250 * Math.Pow(2, attempt - 1))); +} + +public static class FeedFailureClassifier +{ + public static FeedFailureKind Classify(Exception exception) + { + foreach (Exception current in Enumerate(exception)) + { + if (current is HttpRequestException httpException && httpException.StatusCode is HttpStatusCode statusCode) + return Classify(statusCode); + + if (current is TaskCanceledException) + return FeedFailureKind.Transient; + } + + return FeedFailureKind.Unknown; + } + + public static FeedFailureKind Classify(HttpStatusCode statusCode) => statusCode switch { + HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden => FeedFailureKind.Authentication, + HttpStatusCode.RequestTimeout or HttpStatusCode.TooManyRequests => FeedFailureKind.Transient, + >= HttpStatusCode.InternalServerError => FeedFailureKind.Transient, + _ => FeedFailureKind.Unknown, + }; + + private static IEnumerable Enumerate(Exception exception) + { + for (Exception? current = exception; current is not null; current = current.InnerException) + yield return current; + } +} diff --git a/build/publishing/SignedPackagePublisher/PackageInspector.cs b/build/publishing/SignedPackagePublisher/PackageInspector.cs new file mode 100644 index 000000000..f7cee777b --- /dev/null +++ b/build/publishing/SignedPackagePublisher/PackageInspector.cs @@ -0,0 +1,48 @@ +using System.Security.Cryptography; +using NuGet.Packaging; + +namespace SignedPackagePublisher; + +public static class PackageInspector +{ + public static async Task> InspectDirectoryAsync( + string packagesDirectory, + CancellationToken cancellationToken) + { + string root = Path.GetFullPath(packagesDirectory); + if (!Directory.Exists(root)) + throw new DirectoryNotFoundException($"Signed package directory '{root}' does not exist."); + + string[] packagePaths = Directory.GetFiles(root, "*.nupkg", SearchOption.AllDirectories); + if (packagePaths.Length == 0) + throw new InvalidOperationException($"No signed .nupkg files were found in '{root}'."); + + var packages = new List(packagePaths.Length); + foreach (string packagePath in packagePaths.Order(StringComparer.Ordinal)) + { + cancellationToken.ThrowIfCancellationRequested(); + string hash = await ComputeSha256Async(packagePath, cancellationToken); + + using var reader = new PackageArchiveReader(packagePath); + var identity = await reader.GetIdentityAsync(cancellationToken) + ?? throw new InvalidDataException($"Package '{packagePath}' has no NuGet identity."); + + packages.Add(new SignedPackage( + packagePath, + Path.GetRelativePath(root, packagePath).Replace('\\', '/'), + Path.GetFileName(packagePath), + hash, + identity.Id, + identity.Version)); + } + + return packages; + } + + private static async Task ComputeSha256Async(string path, CancellationToken cancellationToken) + { + await using FileStream stream = File.OpenRead(path); + byte[] hash = await SHA256.HashDataAsync(stream, cancellationToken); + return Convert.ToHexStringLower(hash); + } +} diff --git a/build/publishing/SignedPackagePublisher/Program.cs b/build/publishing/SignedPackagePublisher/Program.cs new file mode 100644 index 000000000..6b5cae3f2 --- /dev/null +++ b/build/publishing/SignedPackagePublisher/Program.cs @@ -0,0 +1,103 @@ +namespace SignedPackagePublisher; + +public static class Program +{ + public static async Task Main(string[] args) + { + try + { + Options options = Options.Parse(args); + using var probe = new NuGetFeedProbe( + options.Feed, + GetOptionalEnvironmentVariable(options.FeedTokenEnvironmentVariable), + options.MaxAttempts); + + IReadOnlyList packages = await PackageInspector.InspectDirectoryAsync( + options.PackagesDirectory, + CancellationToken.None); + var planner = new PublishingPlanner(probe, options.MaxConcurrency); + PublishingPlan plan = await planner.CreateAsync(packages, CancellationToken.None); + await PublishingOutput.WriteInventoryAsync( + options.InventoryPath, + options.Feed, + plan, + CancellationToken.None); + + if (plan.HasErrors) + { + Console.Error.WriteLine("Package filtering failed. See the deterministic audit inventory for details."); + return 2; + } + + PublishingOutput.StageIncludedPackages(options.PackageOutputDirectory, plan.IncludedPackages); + PublishingOutput.WriteManifest(options.ManifestPath, options.ManifestIdentity, plan.IncludedPackages); + Console.WriteLine($"Inspected {packages.Count} signed packages; {plan.IncludedPackages.Count} are absent from the target feed."); + Console.WriteLine($"##vso[task.setvariable variable=IncludedPackageCount]{plan.IncludedPackages.Count}"); + return 0; + } + catch (Exception exception) + { + Console.Error.WriteLine(exception); + return 1; + } + } + + private static string? GetOptionalEnvironmentVariable(string? name) + => string.IsNullOrWhiteSpace(name) ? null : Environment.GetEnvironmentVariable(name); +} + +public sealed record Options( + string PackagesDirectory, + string PackageOutputDirectory, + string InventoryPath, + string ManifestPath, + Uri Feed, + string? FeedTokenEnvironmentVariable, + int MaxConcurrency, + int MaxAttempts, + ManifestIdentity ManifestIdentity) +{ + public static Options Parse(string[] args) + { + var values = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (int index = 0; index < args.Length; index += 2) + { + if (index + 1 >= args.Length || !args[index].StartsWith("--", StringComparison.Ordinal)) + throw new ArgumentException("Arguments must be supplied as '--name value' pairs."); + values.Add(args[index][2..], args[index + 1]); + } + + string Required(string name) + => values.TryGetValue(name, out string? value) && !string.IsNullOrWhiteSpace(value) + ? value + : throw new ArgumentException($"Missing required argument '--{name}'."); + int RequiredInt(string name) + => int.TryParse(Required(name), out int value) + ? value + : throw new ArgumentException($"Argument '--{name}' must be an integer."); + int OptionalInt(string name, int fallback) + => values.TryGetValue(name, out string? value) + ? int.Parse(value, System.Globalization.CultureInfo.InvariantCulture) + : fallback; + + return new Options( + Required("packages"), + Required("package-output"), + Required("inventory"), + Required("manifest"), + new Uri(Required("feed"), UriKind.Absolute), + values.GetValueOrDefault("feed-token-env"), + OptionalInt("max-concurrency", 8), + OptionalInt("max-attempts", 4), + new ManifestIdentity( + Required("repository-name"), + Required("build-number"), + Required("branch"), + Required("commit"), + Required("azure-collection-uri"), + Required("azure-project"), + RequiredInt("azure-build-id"), + RequiredInt("azure-definition-id"), + Required("azure-repository-uri"))); + } +} diff --git a/build/publishing/SignedPackagePublisher/PublishingOutput.cs b/build/publishing/SignedPackagePublisher/PublishingOutput.cs new file mode 100644 index 000000000..db29d3cea --- /dev/null +++ b/build/publishing/SignedPackagePublisher/PublishingOutput.cs @@ -0,0 +1,115 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Xml.Linq; +using Microsoft.DotNet.Build.Manifest; + +namespace SignedPackagePublisher; + +public sealed record ManifestIdentity( + string RepositoryName, + string BuildNumber, + string Branch, + string Commit, + string AzureCollectionUri, + string AzureProject, + int AzureBuildId, + int AzureDefinitionId, + string AzureRepositoryUri); + +public sealed record InventoryDocument( + int SchemaVersion, + string Feed, + IReadOnlyList Packages); + +[JsonSourceGenerationOptions( + WriteIndented = true, + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + UseStringEnumConverter = true)] +[JsonSerializable(typeof(InventoryDocument))] +internal sealed partial class InventoryJsonContext : JsonSerializerContext; + +public static class PublishingOutput +{ + public static async Task WriteInventoryAsync( + string path, + Uri feed, + PublishingPlan plan, + CancellationToken cancellationToken) + { + EnsureParentDirectory(path); + var document = new InventoryDocument(1, feed.AbsoluteUri, plan.Inventory); + await using FileStream stream = File.Create(path); + await JsonSerializer.SerializeAsync( + stream, + document, + InventoryJsonContext.Default.InventoryDocument, + cancellationToken); + await stream.WriteAsync("\n"u8.ToArray(), cancellationToken); + } + + public static void StageIncludedPackages(string outputDirectory, IReadOnlyList packages) + { + Directory.CreateDirectory(outputDirectory); + foreach (SignedPackage package in packages) + { + string destination = Path.Combine( + outputDirectory, + $"{package.Id}.{package.Version.ToNormalizedString()}.nupkg"); + File.Copy(package.SourcePath, destination, overwrite: false); + } + } + + public static void WriteManifest( + string path, + ManifestIdentity identity, + IReadOnlyList packages) + { + var build = new BuildModel(new BuildIdentity { + PublishingVersion = PublishingInfraVersion.V3, + Name = identity.RepositoryName, + BuildId = identity.BuildNumber, + Branch = identity.Branch, + Commit = identity.Commit, + IsStable = false, + IsReleaseOnlyPackageVersion = false, + InitialAssetsLocation = $"{identity.AzureCollectionUri.TrimEnd('/')}/{identity.AzureProject}/_apis/build/builds/{identity.AzureBuildId}/artifacts", + AzureDevOpsAccount = GetAzureDevOpsAccount(identity.AzureCollectionUri), + AzureDevOpsProject = identity.AzureProject, + AzureDevOpsBuildNumber = identity.BuildNumber, + AzureDevOpsRepository = identity.AzureRepositoryUri, + AzureDevOpsBranch = identity.Branch, + AzureDevOpsBuildId = identity.AzureBuildId, + AzureDevOpsBuildDefinitionId = identity.AzureDefinitionId, + }); + + foreach (SignedPackage package in packages) + { + var asset = new PackageArtifactModel { + Id = package.Id, + Version = package.Version.ToNormalizedString(), + NonShipping = false, + }; + asset.Attributes["Category"] = "Package"; + build.Artifacts.Packages.Add(asset); + } + + EnsureParentDirectory(path); + File.WriteAllText(path, build.ToXml().ToString(SaveOptions.DisableFormatting)); + } + + private static void EnsureParentDirectory(string path) + { + string? directory = Path.GetDirectoryName(Path.GetFullPath(path)); + if (directory is not null) + Directory.CreateDirectory(directory); + } + + private static string GetAzureDevOpsAccount(string collectionUri) + { + var uri = new Uri(collectionUri); + if (uri.Host.Equals("dev.azure.com", StringComparison.OrdinalIgnoreCase)) + return uri.AbsolutePath.Split('/', StringSplitOptions.RemoveEmptyEntries).First(); + + return uri.Host.Split('.', StringSplitOptions.RemoveEmptyEntries).First(); + } +} diff --git a/build/publishing/SignedPackagePublisher/PublishingPlanner.cs b/build/publishing/SignedPackagePublisher/PublishingPlanner.cs new file mode 100644 index 000000000..6b06e94d0 --- /dev/null +++ b/build/publishing/SignedPackagePublisher/PublishingPlanner.cs @@ -0,0 +1,123 @@ +using NuGet.Packaging.Core; +using System.Collections.Concurrent; + +namespace SignedPackagePublisher; + +public sealed class PublishingPlanner(IPackageFeedProbe feedProbe, int maxConcurrency) +{ + private static readonly PackageIdentityComparer IdentityComparer = PackageIdentityComparer.Default; + + public async Task CreateAsync( + IReadOnlyList packages, + CancellationToken cancellationToken) + { + if (maxConcurrency < 1) + throw new ArgumentOutOfRangeException(nameof(maxConcurrency)); + + var entries = new ConcurrentDictionary(StringComparer.Ordinal); + var canonicalPackages = new List(); + + foreach (IGrouping fileNameGroup in packages.GroupBy( + package => package.FileName, + StringComparer.OrdinalIgnoreCase)) + { + var identities = fileNameGroup + .Select(package => new PackageIdentity(package.Id, package.Version)) + .Distinct(IdentityComparer) + .ToArray(); + if (identities.Length > 1) + { + foreach (SignedPackage package in fileNameGroup) + entries[package.RelativePath] = Entry(package, PackageDecision.Error, "duplicate-filename-different-identity"); + } + } + + foreach (IGrouping identityGroup in packages.GroupBy( + package => new PackageIdentity(package.Id, package.Version), + IdentityComparer)) + { + SignedPackage[] ordered = identityGroup.OrderBy(package => package.RelativePath, StringComparer.Ordinal).ToArray(); + SignedPackage[] unrecorded = ordered.Where(package => !entries.ContainsKey(package.RelativePath)).ToArray(); + if (unrecorded.Length == 0) + continue; + if (unrecorded.Length != ordered.Length) + { + foreach (SignedPackage package in unrecorded) + entries[package.RelativePath] = Entry(package, PackageDecision.Error, "duplicate-identity-in-conflicting-filename-set"); + continue; + } + + if (ordered.Select(package => package.Sha256).Distinct(StringComparer.Ordinal).Count() > 1) + { + foreach (SignedPackage package in ordered) + entries[package.RelativePath] = Entry(package, PackageDecision.Error, "duplicate-identity-different-content"); + continue; + } + + SignedPackage canonical = ordered[0]; + canonicalPackages.Add(canonical); + foreach (SignedPackage duplicate in ordered.Skip(1)) + { + entries[duplicate.RelativePath] = Entry( + duplicate, + PackageDecision.Exclude, + "duplicate-identical", + canonical.RelativePath); + } + } + + using var gate = new SemaphoreSlim(maxConcurrency, maxConcurrency); + await Task.WhenAll(canonicalPackages.Select(async package => { + await gate.WaitAsync(cancellationToken); + try + { + bool exists = await feedProbe.ExistsAsync(package.Id, package.Version, cancellationToken); + entries[package.RelativePath] = Entry( + package, + exists ? PackageDecision.Exclude : PackageDecision.Include, + exists ? "already-exists" : "missing-from-feed"); + } + catch (Exception exception) when (exception is not OperationCanceledException + || !cancellationToken.IsCancellationRequested) + { + entries[package.RelativePath] = Entry( + package, + PackageDecision.Error, + $"feed-query-failed: {exception.Message}"); + } + finally + { + gate.Release(); + } + })); + + PackageInventoryEntry[] inventory = entries.Values + .OrderBy(entry => entry.Id, StringComparer.OrdinalIgnoreCase) + .ThenBy(entry => entry.Version, StringComparer.OrdinalIgnoreCase) + .ThenBy(entry => entry.SourceFile, StringComparer.Ordinal) + .ToArray(); + + SignedPackage[] included = canonicalPackages + .Where(package => entries[package.RelativePath].Decision == PackageDecision.Include) + .OrderBy(package => package.Id, StringComparer.OrdinalIgnoreCase) + .ThenBy(package => package.Version) + .ToArray(); + + return new PublishingPlan(inventory, included); + } + + private static PackageInventoryEntry Entry( + SignedPackage package, + PackageDecision decision, + string reason, + string? canonicalSourceFile = null) + => new( + package.RelativePath, + package.FileName, + package.Sha256, + package.Id, + package.Version.ToNormalizedString(), + decision, + reason, + canonicalSourceFile); +} diff --git a/build/publishing/SignedPackagePublisher/SignedPackagePublisher.csproj b/build/publishing/SignedPackagePublisher/SignedPackagePublisher.csproj new file mode 100644 index 000000000..cab5f8361 --- /dev/null +++ b/build/publishing/SignedPackagePublisher/SignedPackagePublisher.csproj @@ -0,0 +1,20 @@ + + + Exe + net10.0 + 1.0.0 + enable + enable + true + false + false + https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-eng/nuget/v3/index.json + + + + + + + + + diff --git a/build/publishing/promote-build.ps1 b/build/publishing/promote-build.ps1 new file mode 100644 index 000000000..60f8d481d --- /dev/null +++ b/build/publishing/promote-build.ps1 @@ -0,0 +1,48 @@ +param( + [Parameter(Mandatory = $true)][int] $BuildId, + [Parameter(Mandatory = $true)][string] $ChannelName, + [Parameter(Mandatory = $true)][int] $ExpectedChannelId, + [Parameter(Mandatory = $true)][string] $AzdoToken, + [Parameter(Mandatory = $false)][string] $MaestroApiEndpoint = 'https://maestro.dot.net' +) + +$ErrorActionPreference = 'Stop' + +try { + $ci = $true + $disableConfigureToolsetImport = $true + . $PSScriptRoot\..\..\eng\common\tools.ps1 + + $darc = Get-Darc + $channelsJson = & $darc get-channels ` + --output-format json ` + --bar-uri $MaestroApiEndpoint ` + --ci + if ($LastExitCode -ne 0) { + throw "Darc could not list BAR channels." + } + + $channel = @($channelsJson | ConvertFrom-Json) | + Where-Object { $_.name -eq $ChannelName } + if ($channel.Count -ne 1 -or $channel[0].id -ne $ExpectedChannelId) { + throw "BAR channel '$ChannelName' must resolve uniquely to id $ExpectedChannelId." + } + + & $darc add-build-to-channel ` + --id $BuildId ` + --channel $ChannelName ` + --publishing-infra-version 3 ` + --source-branch main ` + --azdev-pat $AzdoToken ` + --bar-uri $MaestroApiEndpoint ` + --ci ` + --verbose + if ($LastExitCode -ne 0) { + throw "Darc failed to promote BAR build $BuildId to '$ChannelName'." + } +} +catch { + Write-Host $_ + Write-PipelineTelemetryError -Category 'PromoteBuild' -Message "Failed to promote BAR build '$BuildId' to '$ChannelName'." + ExitWithExitCode 1 +} diff --git a/docs/publishing-signed-packages.md b/docs/publishing-signed-packages.md new file mode 100644 index 000000000..85201aa4b --- /dev/null +++ b/docs/publishing-signed-packages.md @@ -0,0 +1,19 @@ +# Publishing signed packages through BAR + +The `publish_signed_packages` stage consumes only the `nuget-signed` pipeline artifact produced by the existing Xamarin signing job. It reads each signed `.nupkg` identity and normalized version, queries that exact package in the public `dotnet10` NuGet V3 feed, and creates an Arcade V3 manifest containing only missing packages. Every inspected package and its SHA-256 is recorded in the `SignedPackagePublishingInventory` pipeline artifact. + +The manifest uses Arcade's `PackageArtifactModel` with `Category=Package` and `NonShipping=false`. Arcade's .NET 10 public channel maps that combination to the `dotnet10` shipping feed. The stage does not consume or register `output-windows`, and it does not publish to NuGet.org. + +## One-time Azure DevOps setup + +Publishing is intentionally disabled by `barPublishingEnabled: false` until all setup below is complete: + +1. In the DevDiv project, create the `android-libraries-dotnet10-publishing` environment. +2. Add an **Exclusive lock** check to that environment with one concurrent deployment, and authorize the AndroidX pipeline to use it. The stage sets `lockBehavior: sequential`, so the feed check, BAR registration, and promotion remain in one serialized critical section. +3. Authorize the pipeline to use the `Darc: Maestro Production` service connection. +4. Confirm BAR channel `.NET 10` still has ID `5172` and maps shipping `Package` assets to `https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet10/nuget/v3/index.json`. The promotion script fails before promotion if the name and ID no longer match. +5. Set `barPublishingEnabled` to `true` in `build/ci/variables.yml`. + +The public `dotnet10` feed currently permits anonymous reads. If that policy changes, supply a read token through a secret environment variable and pass its name with `--feed-token-env`; the tool never accepts a token value on its command line. + +The stage uses the same real-sign condition as `build/ci/stage-sign-artifacts.yml`: non-PR `release/*` builds and non-scheduled `main` builds. PRs, scheduled builds, public validation, and all test-signed artifacts are excluded. From 13a4a3bd300b9c11a84585d9f11d7ae1e17718c5 Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Mon, 17 Aug 2026 14:53:12 -0500 Subject: [PATCH 2/6] Fix scoped Arcade restore and artifact download Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- build/ci/stage-publish-signed-artifacts.yml | 17 +++++++++++++++++ build/publishing/NuGet.config | 8 ++++++++ .../SignedPackagePublisher.csproj | 8 ++++---- docs/publishing-signed-packages.md | 2 ++ global.json | 3 +++ 5 files changed, 34 insertions(+), 4 deletions(-) create mode 100644 build/publishing/NuGet.config diff --git a/build/ci/stage-publish-signed-artifacts.yml b/build/ci/stage-publish-signed-artifacts.yml index 1ba56f885..c467e71de 100644 --- a/build/ci/stage-publish-signed-artifacts.yml +++ b/build/ci/stage-publish-signed-artifacts.yml @@ -21,6 +21,8 @@ stages: runOnce: deploy: steps: + - download: none + - checkout: self clean: true fetchDepth: 3 @@ -39,6 +41,18 @@ stages: - task: NuGetAuthenticate@1 displayName: Authenticate to Azure Artifacts + - task: PowerShell@2 + displayName: Configure scoped Arcade restore + inputs: + targetType: inline + script: | + $toolsetDirectory = '$(System.DefaultWorkingDirectory)\artifacts\toolset' + New-Item -ItemType Directory -Force -Path $toolsetDirectory | Out-Null + Copy-Item ` + '$(System.DefaultWorkingDirectory)\build\publishing\NuGet.config' ` + "$toolsetDirectory\NuGet.config" ` + -Force + - task: PowerShell@2 displayName: Filter signed packages and create Arcade V3 manifest inputs: @@ -121,6 +135,9 @@ stages: /p:IsAssetlessBuild=false /p:MaestroApiEndpoint=https://maestro.dot.net /p:OfficialBuildId=$(Build.BuildNumber) + /p:RestoreConfigFile='$(System.DefaultWorkingDirectory)\build\publishing\NuGet.config' + env: + NUGET_CONFIG: $(System.DefaultWorkingDirectory)\build\publishing\NuGet.config - task: AzureCLI@2 displayName: Promote BAR build to $(barTargetChannelName) diff --git a/build/publishing/NuGet.config b/build/publishing/NuGet.config new file mode 100644 index 000000000..d026c2322 --- /dev/null +++ b/build/publishing/NuGet.config @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/build/publishing/SignedPackagePublisher/SignedPackagePublisher.csproj b/build/publishing/SignedPackagePublisher/SignedPackagePublisher.csproj index cab5f8361..8753e6f0e 100644 --- a/build/publishing/SignedPackagePublisher/SignedPackagePublisher.csproj +++ b/build/publishing/SignedPackagePublisher/SignedPackagePublisher.csproj @@ -1,18 +1,18 @@ - + Exe net10.0 - 1.0.0 enable enable true false false - https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-eng/nuget/v3/index.json + + 11.0.0-beta.26381.103 - + diff --git a/docs/publishing-signed-packages.md b/docs/publishing-signed-packages.md index 85201aa4b..360a3a512 100644 --- a/docs/publishing-signed-packages.md +++ b/docs/publishing-signed-packages.md @@ -17,3 +17,5 @@ Publishing is intentionally disabled by `barPublishingEnabled: false` until all The public `dotnet10` feed currently permits anonymous reads. If that policy changes, supply a read token through a secret environment variable and pass its name with `--feed-token-env`; the tool never accepts a token value on its command line. The stage uses the same real-sign condition as `build/ci/stage-sign-artifacts.yml`: non-PR `release/*` builds and non-scheduled `main` builds. PRs, scheduled builds, public validation, and all test-signed artifacts are excluded. + +Publishing tools restore through `build/publishing/NuGet.config`, which adds `dotnet-eng` only for this isolated publishing surface. The repository-wide `NuGet.config` and normal Cake restore sources remain unchanged. diff --git a/global.json b/global.json index d1e00d327..a552e93f2 100644 --- a/global.json +++ b/global.json @@ -1,4 +1,7 @@ { + "tools": { + "dotnet": "11.0.100-preview.7.26381.103" + }, "msbuild-sdks": { "Microsoft.Build.Traversal": "4.1.0", "Microsoft.Build.NoTargets": "3.7.56", From 9ea8a6c15e3805b39c41d0a7d35ac5780ffb9e1e Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Mon, 17 Aug 2026 15:07:11 -0500 Subject: [PATCH 3/6] Use 1ES release job for BAR publishing Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 00d22177-794c-45e9-87a4-4c2cacb26a91 --- build/ci/stage-publish-signed-artifacts.yml | 23 ++++++++------------- build/ci/variables.yml | 1 - docs/publishing-signed-packages.md | 9 ++++---- 3 files changed, 13 insertions(+), 20 deletions(-) diff --git a/build/ci/stage-publish-signed-artifacts.yml b/build/ci/stage-publish-signed-artifacts.yml index c467e71de..330e285de 100644 --- a/build/ci/stage-publish-signed-artifacts.yml +++ b/build/ci/stage-publish-signed-artifacts.yml @@ -8,31 +8,26 @@ stages: lockBehavior: sequential jobs: - - deployment: publish_signed_packages + - job: publish_signed_packages displayName: Filter, register, and promote signed packages timeoutInMinutes: 240 - environment: $(barPublishingEnvironment) pool: name: AzurePipelines-EO image: 1ESPT-Windows2025 demands: Cmd os: windows - strategy: - runOnce: - deploy: - steps: - - download: none - + templateContext: + type: releaseJob + isProduction: true + inputs: + - input: pipelineArtifact + artifactName: nuget-signed + targetPath: $(Pipeline.Workspace)\nuget-signed + steps: - checkout: self clean: true fetchDepth: 3 - - task: DownloadPipelineArtifact@2 - displayName: Download real-signed NuGet packages - inputs: - artifactName: nuget-signed - targetPath: $(Pipeline.Workspace)\nuget-signed - - task: UseDotNet@2 displayName: Install .NET SDK inputs: diff --git a/build/ci/variables.yml b/build/ci/variables.yml index ca7cf3402..d87457c1c 100644 --- a/build/ci/variables.yml +++ b/build/ci/variables.yml @@ -43,7 +43,6 @@ variables: # Signed package publishing remains disabled until the Azure DevOps environment # and service connection setup in docs/publishing-signed-packages.md is complete. barPublishingEnabled: 'false' - barPublishingEnvironment: android-libraries-dotnet10-publishing barTargetFeed: https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet10/nuget/v3/index.json barTargetChannelName: .NET 10 barTargetChannelId: '5172' diff --git a/docs/publishing-signed-packages.md b/docs/publishing-signed-packages.md index 360a3a512..195bdc295 100644 --- a/docs/publishing-signed-packages.md +++ b/docs/publishing-signed-packages.md @@ -8,11 +8,10 @@ The manifest uses Arcade's `PackageArtifactModel` with `Category=Package` and `N Publishing is intentionally disabled by `barPublishingEnabled: false` until all setup below is complete: -1. In the DevDiv project, create the `android-libraries-dotnet10-publishing` environment. -2. Add an **Exclusive lock** check to that environment with one concurrent deployment, and authorize the AndroidX pipeline to use it. The stage sets `lockBehavior: sequential`, so the feed check, BAR registration, and promotion remain in one serialized critical section. -3. Authorize the pipeline to use the `Darc: Maestro Production` service connection. -4. Confirm BAR channel `.NET 10` still has ID `5172` and maps shipping `Package` assets to `https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet10/nuget/v3/index.json`. The promotion script fails before promotion if the name and ID no longer match. -5. Set `barPublishingEnabled` to `true` in `build/ci/variables.yml`. +1. Authorize the AndroidX pipeline to use the `Darc: Maestro Production` service connection. +2. Add an **Exclusive lock** check to that service connection. The stage sets `lockBehavior: sequential`, so the feed check, BAR registration, and promotion remain in one serialized critical section. +3. Confirm BAR channel `.NET 10` still has ID `5172` and maps shipping `Package` assets to `https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet10/nuget/v3/index.json`. The promotion script fails before promotion if the name and ID no longer match. +4. Set `barPublishingEnabled` to `true` in `build/ci/variables.yml`. The public `dotnet10` feed currently permits anonymous reads. If that policy changes, supply a read token through a secret environment variable and pass its name with `--feed-token-env`; the tool never accepts a token value on its command line. From f26a4f7b4ef0323972852870b9f4b031f45ef055 Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Mon, 17 Aug 2026 15:08:43 -0500 Subject: [PATCH 4/6] Stage publishing tooling for 1ES release job Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 00d22177-794c-45e9-87a4-4c2cacb26a91 --- build/ci/stage-publish-signed-artifacts.yml | 55 ++++++++++++++++----- 1 file changed, 44 insertions(+), 11 deletions(-) diff --git a/build/ci/stage-publish-signed-artifacts.yml b/build/ci/stage-publish-signed-artifacts.yml index 330e285de..6f8f3258f 100644 --- a/build/ci/stage-publish-signed-artifacts.yml +++ b/build/ci/stage-publish-signed-artifacts.yml @@ -8,8 +8,42 @@ stages: lockBehavior: sequential jobs: + - job: prepare_publish_tooling + displayName: Prepare signed package publishing tooling + pool: + name: AzurePipelines-EO + image: 1ESPT-Windows2025 + demands: Cmd + os: windows + steps: + - checkout: self + clean: true + fetchDepth: 3 + + - task: CopyFiles@2 + displayName: Stage signed package publishing tooling + inputs: + SourceFolder: $(Build.SourcesDirectory) + Contents: | + global.json + build/publishing/NuGet.config + build/publishing/SignedPackagePublisher/** + build/publishing/promote-build.ps1 + eng/common/** + TargetFolder: $(Build.ArtifactStagingDirectory)\SignedPackagePublishingTooling + + - template: /eng/common/templates-official/steps/publish-pipeline-artifacts.yml + parameters: + is1ESPipeline: true + args: + displayName: Publish signed package publishing tooling + targetPath: $(Build.ArtifactStagingDirectory)\SignedPackagePublishingTooling + artifactName: SignedPackagePublishingTooling + isProduction: false + - job: publish_signed_packages displayName: Filter, register, and promote signed packages + dependsOn: prepare_publish_tooling timeoutInMinutes: 240 pool: name: AzurePipelines-EO @@ -23,11 +57,10 @@ stages: - input: pipelineArtifact artifactName: nuget-signed targetPath: $(Pipeline.Workspace)\nuget-signed + - input: pipelineArtifact + artifactName: SignedPackagePublishingTooling + targetPath: $(Pipeline.Workspace)\publishing-tooling steps: - - checkout: self - clean: true - fetchDepth: 3 - - task: UseDotNet@2 displayName: Install .NET SDK inputs: @@ -41,10 +74,10 @@ stages: inputs: targetType: inline script: | - $toolsetDirectory = '$(System.DefaultWorkingDirectory)\artifacts\toolset' + $toolsetDirectory = '$(Pipeline.Workspace)\publishing-tooling\artifacts\toolset' New-Item -ItemType Directory -Force -Path $toolsetDirectory | Out-Null Copy-Item ` - '$(System.DefaultWorkingDirectory)\build\publishing\NuGet.config' ` + '$(Pipeline.Workspace)\publishing-tooling\build\publishing\NuGet.config' ` "$toolsetDirectory\NuGet.config" ` -Force @@ -61,7 +94,7 @@ stages: "$outputRoot\AssetManifests" | Out-Null dotnet run ` - --project '$(System.DefaultWorkingDirectory)\build\publishing\SignedPackagePublisher\SignedPackagePublisher.csproj' ` + --project '$(Pipeline.Workspace)\publishing-tooling\build\publishing\SignedPackagePublisher\SignedPackagePublisher.csproj' ` --configuration Release ` -- ` --packages '$(Pipeline.Workspace)\nuget-signed' ` @@ -121,7 +154,7 @@ stages: azureSubscription: "Darc: Maestro Production" scriptType: ps scriptLocation: scriptPath - scriptPath: $(System.DefaultWorkingDirectory)\eng\common\sdk-task.ps1 + scriptPath: $(Pipeline.Workspace)\publishing-tooling\eng\common\sdk-task.ps1 arguments: > -task PublishBuildAssets -restore @@ -130,9 +163,9 @@ stages: /p:IsAssetlessBuild=false /p:MaestroApiEndpoint=https://maestro.dot.net /p:OfficialBuildId=$(Build.BuildNumber) - /p:RestoreConfigFile='$(System.DefaultWorkingDirectory)\build\publishing\NuGet.config' + /p:RestoreConfigFile='$(Pipeline.Workspace)\publishing-tooling\build\publishing\NuGet.config' env: - NUGET_CONFIG: $(System.DefaultWorkingDirectory)\build\publishing\NuGet.config + NUGET_CONFIG: $(Pipeline.Workspace)\publishing-tooling\build\publishing\NuGet.config - task: AzureCLI@2 displayName: Promote BAR build to $(barTargetChannelName) @@ -141,7 +174,7 @@ stages: azureSubscription: "Darc: Maestro Production" scriptType: ps scriptLocation: scriptPath - scriptPath: $(System.DefaultWorkingDirectory)\build\publishing\promote-build.ps1 + scriptPath: $(Pipeline.Workspace)\publishing-tooling\build\publishing\promote-build.ps1 arguments: > -BuildId $(BARBuildId) -ChannelName '$(barTargetChannelName)' From 937b2bc0e99707dbd9f18b19618d40fb012e6b4f Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Mon, 17 Aug 2026 15:09:29 -0500 Subject: [PATCH 5/6] Use standard CI job for BAR publishing Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 00d22177-794c-45e9-87a4-4c2cacb26a91 --- build/ci/stage-publish-signed-artifacts.yml | 70 ++++++--------------- 1 file changed, 19 insertions(+), 51 deletions(-) diff --git a/build/ci/stage-publish-signed-artifacts.yml b/build/ci/stage-publish-signed-artifacts.yml index 6f8f3258f..67790ac98 100644 --- a/build/ci/stage-publish-signed-artifacts.yml +++ b/build/ci/stage-publish-signed-artifacts.yml @@ -8,59 +8,27 @@ stages: lockBehavior: sequential jobs: - - job: prepare_publish_tooling - displayName: Prepare signed package publishing tooling - pool: - name: AzurePipelines-EO - image: 1ESPT-Windows2025 - demands: Cmd - os: windows - steps: - - checkout: self - clean: true - fetchDepth: 3 - - - task: CopyFiles@2 - displayName: Stage signed package publishing tooling - inputs: - SourceFolder: $(Build.SourcesDirectory) - Contents: | - global.json - build/publishing/NuGet.config - build/publishing/SignedPackagePublisher/** - build/publishing/promote-build.ps1 - eng/common/** - TargetFolder: $(Build.ArtifactStagingDirectory)\SignedPackagePublishingTooling - - - template: /eng/common/templates-official/steps/publish-pipeline-artifacts.yml - parameters: - is1ESPipeline: true - args: - displayName: Publish signed package publishing tooling - targetPath: $(Build.ArtifactStagingDirectory)\SignedPackagePublishingTooling - artifactName: SignedPackagePublishingTooling - isProduction: false - - job: publish_signed_packages displayName: Filter, register, and promote signed packages - dependsOn: prepare_publish_tooling timeoutInMinutes: 240 pool: name: AzurePipelines-EO image: 1ESPT-Windows2025 demands: Cmd os: windows - templateContext: - type: releaseJob - isProduction: true - inputs: - - input: pipelineArtifact - artifactName: nuget-signed - targetPath: $(Pipeline.Workspace)\nuget-signed - - input: pipelineArtifact - artifactName: SignedPackagePublishingTooling - targetPath: $(Pipeline.Workspace)\publishing-tooling steps: + - download: none + + - checkout: self + clean: true + fetchDepth: 3 + + - task: DownloadPipelineArtifact@2 + displayName: Download real-signed NuGet packages + inputs: + artifactName: nuget-signed + targetPath: $(Pipeline.Workspace)\nuget-signed + - task: UseDotNet@2 displayName: Install .NET SDK inputs: @@ -74,10 +42,10 @@ stages: inputs: targetType: inline script: | - $toolsetDirectory = '$(Pipeline.Workspace)\publishing-tooling\artifacts\toolset' + $toolsetDirectory = '$(System.DefaultWorkingDirectory)\artifacts\toolset' New-Item -ItemType Directory -Force -Path $toolsetDirectory | Out-Null Copy-Item ` - '$(Pipeline.Workspace)\publishing-tooling\build\publishing\NuGet.config' ` + '$(System.DefaultWorkingDirectory)\build\publishing\NuGet.config' ` "$toolsetDirectory\NuGet.config" ` -Force @@ -94,7 +62,7 @@ stages: "$outputRoot\AssetManifests" | Out-Null dotnet run ` - --project '$(Pipeline.Workspace)\publishing-tooling\build\publishing\SignedPackagePublisher\SignedPackagePublisher.csproj' ` + --project '$(System.DefaultWorkingDirectory)\build\publishing\SignedPackagePublisher\SignedPackagePublisher.csproj' ` --configuration Release ` -- ` --packages '$(Pipeline.Workspace)\nuget-signed' ` @@ -154,7 +122,7 @@ stages: azureSubscription: "Darc: Maestro Production" scriptType: ps scriptLocation: scriptPath - scriptPath: $(Pipeline.Workspace)\publishing-tooling\eng\common\sdk-task.ps1 + scriptPath: $(System.DefaultWorkingDirectory)\eng\common\sdk-task.ps1 arguments: > -task PublishBuildAssets -restore @@ -163,9 +131,9 @@ stages: /p:IsAssetlessBuild=false /p:MaestroApiEndpoint=https://maestro.dot.net /p:OfficialBuildId=$(Build.BuildNumber) - /p:RestoreConfigFile='$(Pipeline.Workspace)\publishing-tooling\build\publishing\NuGet.config' + /p:RestoreConfigFile='$(System.DefaultWorkingDirectory)\build\publishing\NuGet.config' env: - NUGET_CONFIG: $(Pipeline.Workspace)\publishing-tooling\build\publishing\NuGet.config + NUGET_CONFIG: $(System.DefaultWorkingDirectory)\build\publishing\NuGet.config - task: AzureCLI@2 displayName: Promote BAR build to $(barTargetChannelName) @@ -174,7 +142,7 @@ stages: azureSubscription: "Darc: Maestro Production" scriptType: ps scriptLocation: scriptPath - scriptPath: $(Pipeline.Workspace)\publishing-tooling\build\publishing\promote-build.ps1 + scriptPath: $(System.DefaultWorkingDirectory)\build\publishing\promote-build.ps1 arguments: > -BuildId $(BARBuildId) -ChannelName '$(barTargetChannelName)' From 93df69f3ab0e454ad72536f0f7f645cb5b940413 Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Mon, 17 Aug 2026 15:27:17 -0500 Subject: [PATCH 6/6] Add safe BAR registration validation Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 00d22177-794c-45e9-87a4-4c2cacb26a91 --- azure-pipelines.yml | 13 ++ build/ci/stage-publish-signed-artifacts.yml | 111 +++++++++++++++--- build/ci/variables.yml | 2 - .../SignedPackagePublisher.Tests.csproj | 1 - build/publishing/promote-build.ps1 | 48 -------- docs/publishing-signed-packages.md | 6 +- 6 files changed, 110 insertions(+), 71 deletions(-) delete mode 100644 build/publishing/promote-build.ps1 diff --git a/azure-pipelines.yml b/azure-pipelines.yml index cefa2de7e..a5ed0f9a0 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -16,6 +16,16 @@ parameters: displayName: Run Extended Tests? type: boolean default: false + +- name: RunBarValidation + displayName: Register a BAR validation build without promotion? + type: boolean + default: false + +- name: BarValidationSignedBuildId + displayName: Official AndroidX build ID supplying nuget-signed + type: number + default: 0 variables: # Variables used by both AndroidX/GPS go in the template @@ -96,3 +106,6 @@ extends: - template: build/ci/stage-sign-artifacts.yml@self - template: build/ci/stage-publish-signed-artifacts.yml@self + parameters: + runValidation: ${{ parameters.RunBarValidation }} + validationSignedBuildId: ${{ parameters.BarValidationSignedBuildId }} diff --git a/build/ci/stage-publish-signed-artifacts.yml b/build/ci/stage-publish-signed-artifacts.yml index 67790ac98..73befd335 100644 --- a/build/ci/stage-publish-signed-artifacts.yml +++ b/build/ci/stage-publish-signed-artifacts.yml @@ -1,10 +1,18 @@ # Filters and publishes only real-signed packages that are absent from the public dotnet10 feed. +parameters: +- name: runValidation + type: boolean + default: false +- name: validationSignedBuildId + type: number + default: 0 + stages: - stage: publish_signed_packages displayName: Publish Signed Packages dependsOn: sign_artifacts - condition: and(succeeded(), eq(variables['barPublishingEnabled'], 'true'), or(and(eq(variables['Build.SourceBranch'], 'refs/heads/main'), ne(variables['Build.Reason'], 'Schedule')), startsWith(variables['Build.SourceBranch'], 'refs/heads/release/')), ne(variables['Build.Reason'], 'PullRequest')) + condition: and(succeeded(), or(and(eq('${{ parameters.runValidation }}', 'true'), eq(variables['Build.Reason'], 'Manual'), ne('${{ parameters.validationSignedBuildId }}', '0')), and(eq(variables['barPublishingEnabled'], 'true'), or(and(eq(variables['Build.SourceBranch'], 'refs/heads/main'), ne(variables['Build.Reason'], 'Schedule')), startsWith(variables['Build.SourceBranch'], 'refs/heads/release/')), ne(variables['Build.Reason'], 'PullRequest')))) lockBehavior: sequential jobs: @@ -23,11 +31,62 @@ stages: clean: true fetchDepth: 3 - - task: DownloadPipelineArtifact@2 - displayName: Download real-signed NuGet packages - inputs: - artifactName: nuget-signed - targetPath: $(Pipeline.Workspace)\nuget-signed + - ${{ if eq(parameters.runValidation, true) }}: + - task: PowerShell@2 + displayName: Validate signed package source build + inputs: + targetType: inline + script: | + $ErrorActionPreference = 'Stop' + $buildId = [int]'${{ parameters.validationSignedBuildId }}' + if ($buildId -le 0) { + throw 'BarValidationSignedBuildId must be a positive Azure DevOps build ID.' + } + + $headers = @{ Authorization = "Bearer $env:SYSTEM_ACCESSTOKEN" } + $buildUri = "$(System.CollectionUri)$(System.TeamProject)/_apis/build/builds/$buildId`?api-version=7.1" + $build = Invoke-RestMethod -Uri $buildUri -Headers $headers + $isRealSigned = ( + ($build.sourceBranch -eq 'refs/heads/main' -and $build.reason -ne 'schedule') -or + $build.sourceBranch.StartsWith('refs/heads/release/', [StringComparison]::Ordinal) + ) -and $build.reason -ne 'pullRequest' + + if ($build.definition.id -ne [int]'$(System.DefinitionId)' -or + $build.result -ne 'succeeded' -or + -not $isRealSigned) { + throw "Build $buildId is not a successful real-sign AndroidX build." + } + + $artifactsUri = "$(System.CollectionUri)$(System.TeamProject)/_apis/build/builds/$buildId/artifacts?api-version=7.1" + $artifacts = Invoke-RestMethod -Uri $artifactsUri -Headers $headers + $signedArtifact = @($artifacts.value | Where-Object { + $_.name -eq 'nuget-signed' -and $_.resource.type -eq 'PipelineArtifact' + }) + if ($signedArtifact.Count -ne 1) { + throw "Build $buildId must contain exactly one nuget-signed pipeline artifact." + } + + Write-Host "Using real-signed packages from AndroidX build $($build.buildNumber) ($buildId)." + env: + SYSTEM_ACCESSTOKEN: $(System.AccessToken) + + - task: DownloadPipelineArtifact@2 + displayName: Download validation real-signed NuGet packages + inputs: + buildType: specific + project: $(System.TeamProject) + definition: $(System.DefinitionId) + buildVersionToDownload: specific + pipelineId: ${{ parameters.validationSignedBuildId }} + artifactName: nuget-signed + targetPath: $(Pipeline.Workspace)\nuget-signed + + - ${{ else }}: + - task: DownloadPipelineArtifact@2 + displayName: Download real-signed NuGet packages + inputs: + artifactName: nuget-signed + targetPath: $(Pipeline.Workspace)\nuget-signed - task: UseDotNet@2 displayName: Install .NET SDK @@ -61,9 +120,18 @@ stages: "$outputRoot\PackageArtifacts", ` "$outputRoot\AssetManifests" | Out-Null + dotnet restore ` + '$(System.DefaultWorkingDirectory)\build\publishing\SignedPackagePublisher\SignedPackagePublisher.csproj' ` + --configfile '$(System.DefaultWorkingDirectory)\build\publishing\NuGet.config' ` + --nologo + if ($LastExitCode -ne 0) { + throw "Signed package publisher restore failed with exit code $LastExitCode." + } + dotnet run ` --project '$(System.DefaultWorkingDirectory)\build\publishing\SignedPackagePublisher\SignedPackagePublisher.csproj' ` --configuration Release ` + --no-restore ` -- ` --packages '$(Pipeline.Workspace)\nuget-signed' ` --package-output "$outputRoot\PackageArtifacts" ` @@ -135,16 +203,21 @@ stages: env: NUGET_CONFIG: $(System.DefaultWorkingDirectory)\build\publishing\NuGet.config - - task: AzureCLI@2 - displayName: Promote BAR build to $(barTargetChannelName) - condition: and(succeeded(), ne(variables['IncludedPackageCount'], '0')) - inputs: - azureSubscription: "Darc: Maestro Production" - scriptType: ps - scriptLocation: scriptPath - scriptPath: $(System.DefaultWorkingDirectory)\build\publishing\promote-build.ps1 - arguments: > - -BuildId $(BARBuildId) - -ChannelName '$(barTargetChannelName)' - -ExpectedChannelId $(barTargetChannelId) - -AzdoToken '$(System.AccessToken)' + - ${{ if ne(parameters.runValidation, true) }}: + - task: AzureCLI@2 + displayName: Publish BAR build through Darc default channels + condition: and(succeeded(), ne(variables['IncludedPackageCount'], '0')) + inputs: + azureSubscription: "Darc: Maestro Production" + scriptType: ps + scriptLocation: scriptPath + scriptPath: $(System.DefaultWorkingDirectory)\eng\common\post-build\publish-using-darc.ps1 + arguments: > + -BuildId $(BARBuildId) + -PublishingInfraVersion 3 + -AzdoToken '$(System.AccessToken)' + -WaitPublishingFinish true + -RequireDefaultChannels true + -SkipAssetsPublishing false + env: + NUGET_CONFIG: $(System.DefaultWorkingDirectory)\build\publishing\NuGet.config diff --git a/build/ci/variables.yml b/build/ci/variables.yml index d87457c1c..c7a946703 100644 --- a/build/ci/variables.yml +++ b/build/ci/variables.yml @@ -44,7 +44,5 @@ variables: # and service connection setup in docs/publishing-signed-packages.md is complete. barPublishingEnabled: 'false' barTargetFeed: https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet10/nuget/v3/index.json - barTargetChannelName: .NET 10 - barTargetChannelId: '5172' barFeedQueryConcurrency: '8' barFeedQueryAttempts: '4' diff --git a/build/publishing/SignedPackagePublisher.Tests/SignedPackagePublisher.Tests.csproj b/build/publishing/SignedPackagePublisher.Tests/SignedPackagePublisher.Tests.csproj index ce9ae6a1d..b43726952 100644 --- a/build/publishing/SignedPackagePublisher.Tests/SignedPackagePublisher.Tests.csproj +++ b/build/publishing/SignedPackagePublisher.Tests/SignedPackagePublisher.Tests.csproj @@ -6,7 +6,6 @@ true false true - https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-eng/nuget/v3/index.json diff --git a/build/publishing/promote-build.ps1 b/build/publishing/promote-build.ps1 deleted file mode 100644 index 60f8d481d..000000000 --- a/build/publishing/promote-build.ps1 +++ /dev/null @@ -1,48 +0,0 @@ -param( - [Parameter(Mandatory = $true)][int] $BuildId, - [Parameter(Mandatory = $true)][string] $ChannelName, - [Parameter(Mandatory = $true)][int] $ExpectedChannelId, - [Parameter(Mandatory = $true)][string] $AzdoToken, - [Parameter(Mandatory = $false)][string] $MaestroApiEndpoint = 'https://maestro.dot.net' -) - -$ErrorActionPreference = 'Stop' - -try { - $ci = $true - $disableConfigureToolsetImport = $true - . $PSScriptRoot\..\..\eng\common\tools.ps1 - - $darc = Get-Darc - $channelsJson = & $darc get-channels ` - --output-format json ` - --bar-uri $MaestroApiEndpoint ` - --ci - if ($LastExitCode -ne 0) { - throw "Darc could not list BAR channels." - } - - $channel = @($channelsJson | ConvertFrom-Json) | - Where-Object { $_.name -eq $ChannelName } - if ($channel.Count -ne 1 -or $channel[0].id -ne $ExpectedChannelId) { - throw "BAR channel '$ChannelName' must resolve uniquely to id $ExpectedChannelId." - } - - & $darc add-build-to-channel ` - --id $BuildId ` - --channel $ChannelName ` - --publishing-infra-version 3 ` - --source-branch main ` - --azdev-pat $AzdoToken ` - --bar-uri $MaestroApiEndpoint ` - --ci ` - --verbose - if ($LastExitCode -ne 0) { - throw "Darc failed to promote BAR build $BuildId to '$ChannelName'." - } -} -catch { - Write-Host $_ - Write-PipelineTelemetryError -Category 'PromoteBuild' -Message "Failed to promote BAR build '$BuildId' to '$ChannelName'." - ExitWithExitCode 1 -} diff --git a/docs/publishing-signed-packages.md b/docs/publishing-signed-packages.md index 195bdc295..1baded1d8 100644 --- a/docs/publishing-signed-packages.md +++ b/docs/publishing-signed-packages.md @@ -10,7 +10,7 @@ Publishing is intentionally disabled by `barPublishingEnabled: false` until all 1. Authorize the AndroidX pipeline to use the `Darc: Maestro Production` service connection. 2. Add an **Exclusive lock** check to that service connection. The stage sets `lockBehavior: sequential`, so the feed check, BAR registration, and promotion remain in one serialized critical section. -3. Confirm BAR channel `.NET 10` still has ID `5172` and maps shipping `Package` assets to `https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet10/nuget/v3/index.json`. The promotion script fails before promotion if the name and ID no longer match. +3. Configure the repository's `main` default channel in Darc to the intended public .NET 10 channel, and confirm that channel maps shipping `Package` assets to `https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet10/nuget/v3/index.json`. Publishing uses Arcade's vendored `publish-using-darc.ps1` with `--default-channels-required`, so it fails safely when no default channel is configured. 4. Set `barPublishingEnabled` to `true` in `build/ci/variables.yml`. The public `dotnet10` feed currently permits anonymous reads. If that policy changes, supply a read token through a secret environment variable and pass its name with `--feed-token-env`; the tool never accepts a token value on its command line. @@ -18,3 +18,7 @@ The public `dotnet10` feed currently permits anonymous reads. If that policy cha The stage uses the same real-sign condition as `build/ci/stage-sign-artifacts.yml`: non-PR `release/*` builds and non-scheduled `main` builds. PRs, scheduled builds, public validation, and all test-signed artifacts are excluded. Publishing tools restore through `build/publishing/NuGet.config`, which adds `dotnet-eng` only for this isolated publishing surface. The repository-wide `NuGet.config` and normal Cake restore sources remain unchanged. + +## Validate BAR registration without promotion + +For a manual feature-branch run, set `RunBarValidation=true` and `BarValidationSignedBuildId` to a successful non-scheduled `main` or `release/*` AndroidX build containing `nuget-signed`. Validation mode rejects test-signed source builds, registers the filtered real-signed assets in BAR, and omits the channel-promotion task from the compiled job. The validation build therefore remains unassociated with `.NET 10` and cannot publish packages to the `dotnet10` feed.