Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions azure-pipelines.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -94,3 +104,8 @@ extends:
os: windows

- template: build/ci/stage-sign-artifacts.yml@self

- template: build/ci/stage-publish-signed-artifacts.yml@self
parameters:
runValidation: ${{ parameters.RunBarValidation }}
validationSignedBuildId: ${{ parameters.BarValidationSignedBuildId }}
223 changes: 223 additions & 0 deletions build/ci/stage-publish-signed-artifacts.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
# 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(), 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:
- job: publish_signed_packages
displayName: Filter, register, and promote signed packages
timeoutInMinutes: 240
pool:
name: AzurePipelines-EO
image: 1ESPT-Windows2025
demands: Cmd
os: windows
steps:
- download: none

- checkout: self
clean: true
fetchDepth: 3

- ${{ 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
inputs:
version: 10.x

- 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:
targetType: inline
script: |
$ErrorActionPreference = 'Stop'
$outputRoot = '$(Build.ArtifactStagingDirectory)\SignedPackagePublishing'
New-Item -ItemType Directory -Force -Path `
"$outputRoot\Audit", `
"$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" `
--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)
/p:RestoreConfigFile='$(System.DefaultWorkingDirectory)\build\publishing\NuGet.config'
env:
NUGET_CONFIG: $(System.DefaultWorkingDirectory)\build\publishing\NuGet.config

- ${{ 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
7 changes: 7 additions & 0 deletions build/ci/variables.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,10 @@ 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'
barTargetFeed: https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet10/nuget/v3/index.json
barFeedQueryConcurrency: '8'
barFeedQueryAttempts: '4'
8 changes: 8 additions & 0 deletions build/publishing/NuGet.config
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="dotnet-public" value="https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public/nuget/v3/index.json" />
<add key="dotnet-eng" value="https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-eng/nuget/v3/index.json" />
</packageSources>
</configuration>
Original file line number Diff line number Diff line change
@@ -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));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
global using NUnit.Framework;
Original file line number Diff line number Diff line change
@@ -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<SignedPackage> 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($"""
<?xml version="1.0"?>
<package>
<metadata>
<id>{id}</id>
<version>{version}</version>
<authors>Test</authors>
<description>Test</description>
</metadata>
</package>
""");
}
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);
}
Loading