Skip to content

Commit 22afdb2

Browse files
authored
Merge pull request #893 from microsoft/gavinbarron-hidi-v2-esrp-releases
fix(hidi): use ESRP for v2 NuGet releases
2 parents e7372e4 + 272fc8a commit 22afdb2

5 files changed

Lines changed: 310 additions & 8 deletions

File tree

‎.azure-pipelines/hidi-release.yml‎

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ pr:
1717

1818
variables:
1919
buildConfiguration: Release
20+
NuGetOrganizationName: 'openapinet'
21+
privateFeedBaseUrl: 'https://microsoftgraph.pkgs.visualstudio.com/0985d294-5762-4bc2-a565-161ef349ca3e/_packaging/GraphDeveloperExperiences_Public'
2022
# Keep OFF until the destination PR lands and source publishing is cut over.
2123
hidiPublishingEnabled: 'false'
2224

@@ -162,7 +164,7 @@ extends:
162164
Copy-Item artifacts\hidi\signing\Microsoft.OpenApi.Hidi.dll src\Microsoft.OpenApi.Hidi\bin\$(buildConfiguration)\net8.0\Microsoft.OpenApi.Hidi.dll
163165
Copy-Item artifacts\hidi\signing\Microsoft.OpenApi.Hidi.exe artifacts\hidi\win-x64\Microsoft.OpenApi.Hidi.exe
164166
New-Item -ItemType Directory -Force '$(Build.ArtifactStagingDirectory)\hidi' | Out-Null
165-
dotnet pack src\Microsoft.OpenApi.Hidi\Microsoft.OpenApi.Hidi.csproj -c $(buildConfiguration) --no-build -o '$(Build.ArtifactStagingDirectory)\hidi'
167+
dotnet pack src\Microsoft.OpenApi.Hidi\Microsoft.OpenApi.Hidi.csproj -c $(buildConfiguration) --no-build --include-symbols --include-source /p:SymbolPackageFormat=snupkg -o '$(Build.ArtifactStagingDirectory)\hidi'
166168
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
167169
Compress-Archive -Path artifacts\hidi\win-x64\* -DestinationPath '$(Build.ArtifactStagingDirectory)\hidi\hidi-win-x64-$(HidiVersion).zip'
168170
Copy-Item artifacts\hidi\win-x64\Microsoft.OpenApi.Hidi.exe '$(Build.ArtifactStagingDirectory)\hidi\Microsoft.OpenApi.Hidi.exe'
@@ -200,6 +202,12 @@ extends:
200202
MaxConcurrency: '50'
201203
MaxRetryAttempts: '5'
202204
PendingAnalysisWaitTimeoutMinutes: '5'
205+
- task: CopyFiles@2
206+
displayName: Include private-feed version check in Hidi artifact
207+
inputs:
208+
SourceFolder: '$(Build.SourcesDirectory)\scripts'
209+
Contents: 'check-nuget-package-published.ps1'
210+
TargetFolder: '$(Build.ArtifactStagingDirectory)\hidi\scripts'
203211
- stage: publish
204212
dependsOn: build
205213
condition: and(succeeded(), eq(variables.hidiPublishingEnabled, 'true'), startsWith(variables['Build.SourceBranch'], 'refs/tags/hidi-v2.'))
@@ -222,15 +230,50 @@ extends:
222230
if ([version]($tag -split '-')[0] -le [version]'2.12.2') { throw "Never republish the source migration baseline or an older hidi version." }
223231
$package = "$(Pipeline.Workspace)\hidi\Microsoft.OpenApi.Hidi.$tag.nupkg"
224232
if (-not (Test-Path $package)) { throw "Missing exact hidi package: $package" }
233+
$symbols = "$(Pipeline.Workspace)\hidi\Microsoft.OpenApi.Hidi.$tag.snupkg"
234+
if (-not (Test-Path $symbols)) { throw "Missing exact hidi symbols: $symbols" }
225235
Write-Host "##vso[task.setvariable variable=HidiReleaseVersion]$tag"
226236
displayName: Verify new hidi release version and exact package
227-
- task: 1ES.PublishNuget@1
228-
displayName: Publish hidi NuGet tool
237+
- task: PowerShell@2
238+
displayName: Check whether Hidi NuGet version is already published
239+
inputs:
240+
targetType: filePath
241+
filePath: '$(Pipeline.Workspace)\hidi\scripts\check-nuget-package-published.ps1'
242+
arguments: '-PackageId "Microsoft.OpenApi.Hidi" -PackageDirectory "$(Pipeline.Workspace)\hidi" -NuGetServiceIndexUrl "$(privateFeedBaseUrl)/nuget/v3/index.json"'
243+
pwsh: true
244+
env:
245+
FEED_ACCESS_TOKEN: $(System.AccessToken)
246+
- task: CopyFiles@2
247+
displayName: Stage exact Hidi NuGet packages for ESRP release
248+
condition: and(succeeded(), ne(variables['nugetAlreadyPublished'], 'true'))
249+
inputs:
250+
SourceFolder: '$(Pipeline.Workspace)\hidi'
251+
Contents: |
252+
Microsoft.OpenApi.Hidi.$(HidiReleaseVersion).nupkg
253+
Microsoft.OpenApi.Hidi.$(HidiReleaseVersion).snupkg
254+
TargetFolder: '$(Pipeline.Workspace)\nuget-packages\$(NuGetOrganizationName)\hidi'
255+
CleanTargetFolder: true
256+
- task: EsrpRelease@14
257+
displayName: ESRP Release - Hidi NuGet
258+
condition: and(succeeded(), ne(variables['nugetAlreadyPublished'], 'true'))
229259
inputs:
230-
packagesToPush: '$(Pipeline.Workspace)\hidi\Microsoft.OpenApi.Hidi.$(HidiReleaseVersion).nupkg'
231-
nuGetFeedType: external
232-
publishFeedCredentials: OpenAPI Nuget Connection
233-
packageParentPath: '$(Pipeline.Workspace)\hidi'
260+
connectedservicename: 'Federated DevX ESRP Managed Identity Connection'
261+
usemanagedidentity: false
262+
keyvaultname: 'akv-prod-eastus'
263+
authcertname: 'ReferenceLibraryPrivateCert'
264+
signcertname: 'ReferencePackagePublisherCertificate'
265+
clientid: '65035b7f-7357-4f29-bf25-c5ee5c3949f8'
266+
intent: 'packagedistribution'
267+
contenttype: 'NuGet'
268+
organizationname: '$(NuGetOrganizationName)'
269+
contentsource: 'Folder'
270+
folderlocation: '$(Pipeline.Workspace)\nuget-packages\$(NuGetOrganizationName)\hidi'
271+
waitforreleasecompletion: true
272+
owners: 'christiano@microsoft.com,ramsess@microsoft.com,gavinbarron@microsoft.com,jingjingjia@microsoft.com,peombwa@microsoft.com,treicys@microsoft.com'
273+
approvers: 'christiano@microsoft.com,ramsess@microsoft.com,gavinbarron@microsoft.com,jingjingjia@microsoft.com,peombwa@microsoft.com,treicys@microsoft.com'
274+
serviceendpointurl: 'https://api.esrp.microsoft.com/'
275+
mainpublisher: 'ESRPRELPACMAN'
276+
domaintenantid: 'cdc5aeea-15c5-4db6-b079-fcadd2505dc2'
234277
- task: GitHubRelease@1
235278
displayName: Attach signed hidi release artifacts
236279
inputs:

‎.github/workflows/sonarcloud.yml‎

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,14 +57,70 @@ jobs:
5757
restore-keys: ${{ runner.os }}-sonar
5858
- name: Install SonarCloud scanner
5959
run: dotnet tool install dotnet-sonarscanner --create-manifest-if-needed
60+
- name: Install PowerShell test dependency
61+
shell: pwsh
62+
run: Install-Module Pester -RequiredVersion 5.7.1 -Scope CurrentUser -Force
63+
- name: Test Hidi NuGet helper with measured coverage
64+
shell: pwsh
65+
run: |
66+
$ErrorActionPreference = 'Stop'
67+
Import-Module Pester -RequiredVersion 5.7.1 -ErrorAction Stop
68+
$helperPath = 'scripts/check-nuget-package-published.ps1'
69+
$helper = (Resolve-Path $helperPath).Path
70+
$outputRoot = Join-Path $PWD 'artifacts\hidi\powershell-coverage'
71+
New-Item -ItemType Directory -Force -Path $outputRoot | Out-Null
72+
$configuration = New-PesterConfiguration
73+
$configuration.Run.Path = 'test\scripts\check-nuget-package-published.Tests.ps1'
74+
$configuration.Run.PassThru = $true
75+
$configuration.CodeCoverage.Enabled = $true
76+
$configuration.CodeCoverage.Path = $helper
77+
$configuration.CodeCoverage.OutputFormat = 'JaCoCo'
78+
$configuration.CodeCoverage.OutputPath = Join-Path $outputRoot 'pester-coverage.xml'
79+
$configuration.CodeCoverage.CoveragePercentTarget = 80
80+
$result = Invoke-Pester -Configuration $configuration
81+
if ($result.FailedCount -gt 0 -or $result.PassedCount -lt 24) {
82+
throw "Hidi NuGet helper tests failed or did not execute all 24 cases."
83+
}
84+
$report = [xml](Get-Content $configuration.CodeCoverage.OutputPath.Value -Raw)
85+
$sourceFiles = @($report.SelectNodes('//sourcefile'))
86+
if ($sourceFiles.Count -ne 1 -or $sourceFiles[0].name -ne [IO.Path]::GetFileName($helper)) {
87+
throw 'Expected measured coverage of only the Hidi NuGet helper.'
88+
}
89+
$lines = @($sourceFiles[0].SelectNodes('line'))
90+
$sourceLineCount = @(Get-Content $helper).Count
91+
$lineNumbers = @($lines | ForEach-Object { [int]$_.nr })
92+
if ($lines.Count -eq 0 -or @($lineNumbers | Select-Object -Unique).Count -ne $lines.Count -or
93+
@($lines | Where-Object {
94+
[int]$_.nr -le 0 -or [int]$_.nr -gt $sourceLineCount -or
95+
-not $_.HasAttribute('ci') -or -not $_.HasAttribute('mi') -or
96+
[int]$_.ci -lt 0 -or [int]$_.mi -lt 0 -or ([int]$_.ci + [int]$_.mi) -le 0
97+
}).Count -gt 0) {
98+
throw 'Missing or invalid measured helper coverage lines.'
99+
}
100+
$covered = @($lines | Where-Object { [int]$_.ci -gt 0 }).Count
101+
if ($covered / $lines.Count -lt 0.8) {
102+
throw "Insufficient measured Hidi helper coverage: $covered/$($lines.Count) lines."
103+
}
104+
$coverage = [xml]'<coverage version="1"/>'
105+
$file = $coverage.CreateElement('file')
106+
$file.SetAttribute('path', $helperPath)
107+
[void]$coverage.DocumentElement.AppendChild($file)
108+
foreach ($line in $lines) {
109+
$entry = $coverage.CreateElement('lineToCover')
110+
$entry.SetAttribute('lineNumber', $line.nr)
111+
$entry.SetAttribute('covered', ([int]$line.ci -gt 0).ToString().ToLowerInvariant())
112+
[void]$file.AppendChild($entry)
113+
}
114+
$coverage.Save((Join-Path $outputRoot 'sonar-coverage.xml'))
115+
Write-Host "Measured Hidi NuGet helper coverage: $covered/$($lines.Count) lines; Sonar generic report generated."
60116
- name: Build and analyze
61117
env:
62118
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any
63119
CollectCoverage: true
64120
CoverletOutputFormat: 'opencover' # https://github.com/microsoft/vstest/issues/4014#issuecomment-1307913682
65121
shell: pwsh
66122
run: |
67-
dotnet tool run dotnet-sonarscanner begin /k:"microsoft_OpenAPI.NET.OData" /o:"microsoft" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" /d:sonar.host.url="https://sonarcloud.io" /d:sonar.cs.opencover.reportsPaths="test/**/coverage.net8.0.opencover.xml,artifacts/hidi/coverage/*opencover*.xml"
123+
dotnet tool run dotnet-sonarscanner begin /k:"microsoft_OpenAPI.NET.OData" /o:"microsoft" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" /d:sonar.host.url="https://sonarcloud.io" /d:sonar.cs.opencover.reportsPaths="test/**/coverage.net8.0.opencover.xml,artifacts/hidi/coverage/*opencover*.xml" /d:sonar.coverageReportPaths="artifacts/hidi/powershell-coverage/sonar-coverage.xml"
68124
dotnet workload restore
69125
dotnet build
70126
dotnet test test\Microsoft.OpenAPI.OData.Reader.Tests\Microsoft.OpenAPI.OData.Reader.Tests.csproj --no-build --verbosity normal /p:CollectCoverage=true /p:CoverletOutputFormat=opencover

‎README.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,20 @@ imported commits can also be retained by the later main-branch migration.
103103
Destination hidi release and production publishing are disabled until source
104104
cutover; OpenAPI.NET remains the publisher in the meantime.
105105

106+
The gated hidi NuGet release uses `EsrpRelease@14`, staging only the exact
107+
`Microsoft.OpenApi.Hidi` package and its `.snupkg` symbols from the Hidi build
108+
artifact. The authenticated private-feed version check is ported from
109+
[microsoft/OpenAPI.NET#3107](https://github.com/microsoft/OpenAPI.NET/pull/3107).
110+
An existing version skips ESRP on a re-run; authentication, network, and malformed
111+
feed responses fail closed. Releases must use an exact `hidi-v2.<version>` tag
112+
matching the project version and be newer than the `2.12.2` migration baseline.
113+
This pipeline implementation does not enable publishing or authorize resources.
114+
115+
The SonarCloud workflow runs the private-feed helper tests with Pester 5.7.1 and
116+
converts measured JaCoCo line hits to Sonar generic coverage, alongside the
117+
existing C# OpenCover reports. Run the helper tests locally with
118+
`Import-Module Pester -RequiredVersion 5.7.1; Invoke-Pester .\test\scripts\check-nuget-package-published.Tests.ps1`.
119+
106120
---
107121

108122
# Contributing
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
4+
<#
5+
.SYNOPSIS
6+
Checks whether a NuGet artifact's version exists in an authenticated Azure Artifacts feed.
7+
.DESCRIPTION
8+
Resolves the package content endpoint from the private feed's NuGet v3 service index.
9+
Sets nugetAlreadyPublished for the ESRP release steps; only a missing package or version
10+
permits publishing. Feed authentication and other lookup failures fail the step.
11+
.NOTES
12+
Ported from microsoft/OpenAPI.NET scripts/check-nuget-package-published.ps1
13+
at e1a75437b76ebfc7c9eb446e9fd0b59a21afb14b (#3107), with fail-closed validation
14+
of malformed feed version lists.
15+
#>
16+
[CmdletBinding()]
17+
param(
18+
[Parameter(Mandatory = $true)]
19+
[string]$PackageDirectory,
20+
[Parameter(Mandatory = $true)]
21+
[string]$PackageId,
22+
[Parameter(Mandatory = $true)]
23+
[string]$NuGetServiceIndexUrl,
24+
[string]$FeedAccessToken = $env:FEED_ACCESS_TOKEN
25+
)
26+
27+
$ErrorActionPreference = 'Stop'
28+
29+
function Assert-PrivateFeedUrl {
30+
param([string]$Url)
31+
32+
$uri = [uri]$Url
33+
if (-not $uri.IsAbsoluteUri -or $uri.Scheme -ne 'https' -or
34+
($uri.Host -ne 'pkgs.dev.azure.com' -and -not $uri.Host.EndsWith('.pkgs.visualstudio.com'))) {
35+
throw "NuGet lookups must use an HTTPS Azure Artifacts feed: $Url"
36+
}
37+
}
38+
39+
Assert-PrivateFeedUrl -Url $NuGetServiceIndexUrl
40+
if ([string]::IsNullOrWhiteSpace($FeedAccessToken)) {
41+
throw 'FEED_ACCESS_TOKEN is required to query the private NuGet feed.'
42+
}
43+
44+
$packagePattern = '^' + [regex]::Escape($PackageId) + '\.(\d[\w\.\-]*)\.nupkg$'
45+
$packages = @(Get-ChildItem -Path $PackageDirectory -File -Filter "$PackageId.*.nupkg" |
46+
Where-Object { $_.Name -match $packagePattern })
47+
if ($packages.Count -ne 1) {
48+
throw "Expected exactly one $PackageId nupkg to publish; found $($packages.Count)."
49+
}
50+
$version = [regex]::Match($packages[0].Name, $packagePattern, 'IgnoreCase').Groups[1].Value
51+
$id = $PackageId.ToLowerInvariant()
52+
$credentials = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes("AzureDevOps:$FeedAccessToken"))
53+
$headers = @{
54+
'Authorization' = "Basic $credentials"
55+
'User-Agent' = 'openapi-azdo-pipeline'
56+
}
57+
58+
$index = Invoke-RestMethod -Uri $NuGetServiceIndexUrl -Headers $headers -MaximumRedirection 0
59+
$resource = $index.resources | Where-Object { $_.'@type' -eq 'PackageBaseAddress/3.0.0' } | Select-Object -First 1
60+
if ([string]::IsNullOrWhiteSpace($resource.'@id')) {
61+
throw "No PackageBaseAddress resource found in the NuGet service index at $NuGetServiceIndexUrl"
62+
}
63+
$uri = "$($resource.'@id'.TrimEnd('/'))/$id/index.json"
64+
Assert-PrivateFeedUrl -Url $uri
65+
66+
try {
67+
$response = Invoke-RestMethod -Uri $uri -Headers $headers -MaximumRedirection 0
68+
if ($null -eq $response.versions) {
69+
throw "No versions returned for NuGet $id by the private feed."
70+
}
71+
if ($response.versions -isnot [array] -or @($response.versions | Where-Object {
72+
$_ -isnot [string] -or $_ -notmatch '^\d[\w\.\-]*$'
73+
}).Count -gt 0) {
74+
throw "Invalid version list returned for NuGet $id by the private feed."
75+
}
76+
$alreadyPublished = $response.versions -contains $version
77+
}
78+
catch {
79+
if ([int]$_.Exception.Response.StatusCode -eq 404) {
80+
$alreadyPublished = $false
81+
}
82+
else {
83+
throw
84+
}
85+
}
86+
87+
if ($alreadyPublished) {
88+
Write-Host "NuGet $id $version already present in the private feed; skipping ESRP release (idempotent re-run)."
89+
}
90+
else {
91+
Write-Host "NuGet $id $version not found in the private feed; will publish via ESRP."
92+
}
93+
Write-Host "##vso[task.setvariable variable=nugetAlreadyPublished]$($alreadyPublished.ToString().ToLowerInvariant())"

0 commit comments

Comments
 (0)