From 28c55673536d101ede8e4e23570d0760fe2ea19d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Kr=C3=BCger?= Date: Wed, 5 Aug 2026 12:33:40 +0200 Subject: [PATCH 1/2] Add Azure DevOps NuGet release pipeline for nuget.org publishing Manual-only OneBranch release pipeline that consumes the signed packages from the CosmosDB-Shell-Official build and pushes them to nuget.org behind the nuget-org-release environment approval. Defaults to dryRun=true. --- .pipelines/CosmosDB-Shell-Release-NuGet.yml | 206 ++++++++++++++++++++ 1 file changed, 206 insertions(+) create mode 100644 .pipelines/CosmosDB-Shell-Release-NuGet.yml diff --git a/.pipelines/CosmosDB-Shell-Release-NuGet.yml b/.pipelines/CosmosDB-Shell-Release-NuGet.yml new file mode 100644 index 00000000..2513e187 --- /dev/null +++ b/.pipelines/CosmosDB-Shell-Release-NuGet.yml @@ -0,0 +1,206 @@ +################################################################################# +# CosmosDBShell - Release: publish packages to nuget.org # +# # +# This pipeline does NOT build or sign anything. It consumes the already-built # +# and Microsoft-signed NuGet packages produced by the Official build pipeline # +# (.pipelines/CosmosDB-Shell-Official.yml) and pushes them to the public # +# nuget.org feed, gated behind a manual approval on the # +# 'nuget-org-release' Azure DevOps Environment. # +# # +# One-time setup required before the first live run (see notes at bottom): # +# 1. nuget.org: create/own the 'CosmosDBShell' package ID and the # +# 'CosmosDBShell.FrameworkDependent' package ID under the 'cosmosdbshell' # +# organization and generate a scoped API key covering both. # +# 2. Azure DevOps: create a NuGet service connection named # +# '$(NuGetServiceConnection)' pointing at $(NuGetOrgSource) with that key. # +# 3. Azure DevOps: create an Environment named 'nuget-org-release' and add # +# the required approvers as an approval check. # +# 4. Register this YAML as a new pipeline named 'CosmosDB-Shell-Release-NuGet'# +# and confirm the 'source:' below matches the Official build's pipeline # +# name in your ADO project. # +################################################################################# + +# Release is initiated manually only. It never runs on commits or PRs. +trigger: none +pr: none + +parameters: + - name: "dryRun" + displayName: "Dry run (validate the package set but skip the nuget.org push)" + type: boolean + default: true + + - name: "debug" + displayName: "Enable debug output" + type: boolean + default: false + +variables: + system.debug: ${{ parameters.debug }} + + # Name of the Azure DevOps NuGet service connection that stores the + # nuget.org API key. Create this under Project settings > Service connections. + NuGetServiceConnection: "nuget-org-cosmosdbshell" + + # Public nuget.org v3 push source. + NuGetOrgSource: "https://api.nuget.org/v3/index.json" + + # Folder (inside the downloaded Official-build artifact) that holds the signed + # *.nupkg files. The Official pipeline publishes out\ as 'cosmos_shell_all', + # and packs the packages into out\nupkg. + PackagesArtifactName: "cosmos_shell_all" + PackagesSubfolder: "nupkg" + +resources: + pipelines: + # Pull the signed packages from the most recent successful Official build. + # 'source' must match the *name* of the Official pipeline in Azure DevOps. + - pipeline: officialBuild + source: CosmosDB-Shell-Official + trigger: none # never auto-trigger a release when a build completes + + repositories: + - repository: templates + type: git + name: OneBranch.Pipelines/GovernedTemplates + ref: refs/heads/main + +extends: + template: v2/OneBranch.Official.CrossPlat.yml@templates # https://aka.ms/obpipelines/templates + parameters: + featureFlags: + WindowsHostVersion: + Version: 2022 + Network: R1 + cloudvault: + enabled: false + globalSdl: # https://aka.ms/obpipelines/sdl + tsa: + enabled: true + policheck: + break: true + + stages: + - stage: release + displayName: "Publish to nuget.org" + jobs: + # A deployment job is used so the 'nuget-org-release' Environment's + # approval check fires before any push happens. + - deployment: push_nuget_org + displayName: "Push signed packages to nuget.org" + environment: nuget-org-release + pool: + type: windows # 1ES Official requires pool.type. + variables: + # 1ES requires an output directory even though this job only + # downloads and pushes; nothing new is produced here. + ob_outputDirectory: '$(Build.SourcesDirectory)\out' + ob_artifactBaseName: cosmos_shell_release + strategy: + runOnce: + deploy: + steps: + - task: UseDotNet@2 + displayName: "Install .NET SDK" + inputs: + packageType: "sdk" + useGlobalJson: true + performMultiLevelLookup: true + + # Satisfy the OneBranch/1ES template contract: ob_outputDirectory + # must exist even though this release job produces nothing new + # (it only downloads and pushes). The template uploads this + # directory as an artifact after the last user step, which can + # fail if the folder was never created. + - task: PowerShell@2 + displayName: "Ensure output directory exists" + inputs: + targetType: inline + pwsh: true + script: | + New-Item -ItemType Directory -Path '$(Build.SourcesDirectory)\out' -Force | Out-Null + + # Download the signed packages from the Official build. + # Lands at: $(Pipeline.Workspace)\officialBuild\\\*.nupkg + - download: officialBuild + artifact: $(PackagesArtifactName) + displayName: "Download signed packages from Official build" + + - task: PowerShell@2 + displayName: "Validate package set before publishing" + inputs: + targetType: inline + pwsh: true + script: | + $ErrorActionPreference = 'Stop' + + $pkgDir = Join-Path '$(Pipeline.Workspace)' 'officialBuild\$(PackagesArtifactName)\$(PackagesSubfolder)' + if (-not (Test-Path $pkgDir)) { + throw "Package directory not found: $pkgDir. Confirm the Official build published the '$(PackagesArtifactName)' artifact with a '$(PackagesSubfolder)' folder." + } + + $packages = Get-ChildItem -Path $pkgDir -Filter *.nupkg -File + if (-not $packages -or $packages.Count -eq 0) { + throw "No .nupkg files found in $pkgDir." + } + + # Every RID-specific tool package must be present, plus exactly + # one non-RID 'pointer' package that ties them together. + $ridPatterns = @( + '^CosmosDBShell\.win-x64\..+\.nupkg$', + '^CosmosDBShell\.win-arm64\..+\.nupkg$', + '^CosmosDBShell\.linux-x64\..+\.nupkg$', + '^CosmosDBShell\.linux-arm64\..+\.nupkg$', + '^CosmosDBShell\.osx-x64\..+\.nupkg$', + '^CosmosDBShell\.osx-arm64\..+\.nupkg$' + ) + + foreach ($pattern in $ridPatterns) { + if (-not ($packages | Where-Object { $_.Name -match $pattern })) { + throw "Missing expected RID package matching: $pattern" + } + } + + # The framework-dependent tool package (requires .NET 10 + # on the target machine) ships alongside the RID set. + $fdd = $packages | Where-Object { $_.Name -match '^CosmosDBShell\.FrameworkDependent\..+\.nupkg$' } + if (-not $fdd -or $fdd.Count -ne 1) { + $names = @($fdd | ForEach-Object { $_.Name }) + throw "Expected exactly one framework-dependent package (CosmosDBShell.FrameworkDependent.*). Found: $($names -join ', ')" + } + + $pointer = $packages | Where-Object { + $_.Name -notmatch '^CosmosDBShell\.(win-x64|win-arm64|linux-x64|linux-arm64|osx-x64|osx-arm64|FrameworkDependent)\..+\.nupkg$' + } + if (-not $pointer -or $pointer.Count -ne 1) { + $names = @($pointer | ForEach-Object { $_.Name }) + throw "Expected exactly one pointer (non-RID) package. Found: $($names -join ', ')" + } + + Write-Host "Packages to publish:" + $packages | Sort-Object Name | ForEach-Object { + Write-Host " - $($_.Name) [$($_.Length) bytes]" + } + + Write-Host "Target feed: $(NuGetOrgSource)" + + Write-Host "##vso[task.setvariable variable=ResolvedPackageDir]$pkgDir" + + - ${{ if eq(parameters.dryRun, true) }}: + - task: PowerShell@2 + displayName: "Dry run - skipping nuget.org push" + inputs: + targetType: inline + pwsh: true + script: | + Write-Host "dryRun=true: validated the package set but did not push to $(NuGetOrgSource)." + Write-Host "Re-run with dryRun=false to publish." + + - ${{ if eq(parameters.dryRun, false) }}: + - task: NuGetCommand@2 + displayName: "Push packages to nuget.org" + inputs: + command: "push" + packagesToPush: '$(ResolvedPackageDir)\*.nupkg' + nuGetFeedType: "external" + publishFeedCredentials: "$(NuGetServiceConnection)" From 59402d62e39a5d9014174252b9805b6972bd8936 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Kr=C3=BCger?= Date: Tue, 18 Aug 2026 11:46:00 +0200 Subject: [PATCH 2/2] Fix duplicate-package validation gap and remove stray exes from build artifact CosmosDB-Shell-Release-NuGet.yml: the RID validation only checked that at least one package matched each pattern, so a leftover duplicate (e.g. two versions of the win-x64 package) would still pass and get pushed to nuget.org alongside the intended one. Each RID pattern now must match exactly one file; a duplicate throws and lists the matched names. CosmosDB-Shell-Official.yml: the published cosmos_shell_all artifact contained two loose Windows exes (out\win-x64\CosmosDBShell.exe, out\win-arm64\CosmosDBShell.exe) alongside the zips and nupkgs, kept only so BinSkim had PE files to scan (it doesn't descend into zip/nupkg archives). Added a step that copies those two exes to _binskim-scan\ outside out\ before the final trim, switched ob_sdl_binskim_scanOutputDirectoryOnly to false so BinSkim scans the whole checkout instead of just out\, and dropped win-x64/win-arm64 from the trim step's keep-list. The published artifact now contains only zip/nupkg/_manifest; BinSkim still gets the same signed exes to scan from the staged location. Addresses a Copilot review comment on PR #192 and the reported stray-exe artifact issue. --- .pipelines/CosmosDB-Shell-Official.yml | 38 ++++++++++++++++++--- .pipelines/CosmosDB-Shell-Release-NuGet.yml | 8 ++++- 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/.pipelines/CosmosDB-Shell-Official.yml b/.pipelines/CosmosDB-Shell-Official.yml index e88dec77..47daabbe 100644 --- a/.pipelines/CosmosDB-Shell-Official.yml +++ b/.pipelines/CosmosDB-Shell-Official.yml @@ -82,7 +82,11 @@ extends: OneES_SbomNugetSDLPath: out\nupkg # https://aka.ms/obpipelines/sdl ob_sdl_binskim_enabled: true - ob_sdl_binskim_scanOutputDirectoryOnly: true + # Scan the whole checkout, not just ob_outputDirectory: the Windows exes + # BinSkim needs are staged outside out\ (see "Stage Windows executables + # for BinSkim" below) so the published cosmos_shell_all artifact only + # ever contains zip/nupkg, never loose exes. + ob_sdl_binskim_scanOutputDirectoryOnly: false ob_sdl_binskim_break: true # always break the build on binskim issues, even if TSA enabled. You can disable it by setting to 'false' ob_sdl_roslyn_break: true ${{ if eq(variables['Build.SourceBranch'], 'refs/heads/main') }}: # conditionally enable symbolsPublishing for main branch only @@ -524,6 +528,29 @@ extends: # (SBOM + signed build.manifest) on its own afterwards. # This MUST run before the NuGet push, because the 1ES Pipeline Template # injects a full-tree CodeSign scan after the last user step group. + # + # The Windows win-x64/win-arm64 exes are staged to a location outside + # out\ first (below) so BinSkim (scanning the whole checkout, see + # ob_sdl_binskim_scanOutputDirectoryOnly above) still has loose PE files + # to scan, without those exes ending up duplicated in the artifact + # alongside the already-zipped copies in out\zip. + - task: PowerShell@2 + displayName: "Stage Windows executables for BinSkim (outside published artifact)" + condition: always() + inputs: + targetType: inline + script: | + $outDir = "$(Build.SourcesDirectory)\out" + $scanDir = "$(Build.SourcesDirectory)\_binskim-scan" + foreach ($rid in @('win-x64', 'win-arm64')) { + $source = Join-Path $outDir $rid + if (Test-Path $source) { + $target = Join-Path $scanDir $rid + New-Item -ItemType Directory -Path $target -Force | Out-Null + Copy-Item -Path (Join-Path $source '*') -Destination $target -Recurse -Force + } + } + - task: PowerShell@2 displayName: "Clean non-shipping build outputs before SDL" condition: always() @@ -554,10 +581,11 @@ extends: # Whitelist approach: keep only the dirs we explicitly want shipped # in the cosmos_shell_all artifact. Anything else (per-RID publish # folders, nupkg-payload sign-staging, future intermediates) is noise. - # win-x64 and win-arm64 are kept so SDL/BinSkim has loose PE files - # to scan (it does not descend into the zip/nupkg archives, and - # ob_sdl_binskim_scanOutputDirectoryOnly limits it to out\). - $keep = @('zip', 'nupkg', '_manifest', 'win-x64', 'win-arm64') + # win-x64/win-arm64 are intentionally NOT kept — those exes are + # already zipped into out\zip and were staged to _binskim-scan\ + # above for scanning, so keeping a third loose copy here would + # just duplicate them in the published artifact. + $keep = @('zip', 'nupkg', '_manifest') Get-ChildItem -Path $outDir -Directory -Force -ErrorAction SilentlyContinue | Where-Object { $keep -notcontains $_.Name } | ForEach-Object { Write-Host "Trimming $($_.FullName) from out\" Remove-Item -Recurse -Force $_.FullName diff --git a/.pipelines/CosmosDB-Shell-Release-NuGet.yml b/.pipelines/CosmosDB-Shell-Release-NuGet.yml index 2513e187..099697d4 100644 --- a/.pipelines/CosmosDB-Shell-Release-NuGet.yml +++ b/.pipelines/CosmosDB-Shell-Release-NuGet.yml @@ -156,9 +156,15 @@ extends: ) foreach ($pattern in $ridPatterns) { - if (-not ($packages | Where-Object { $_.Name -match $pattern })) { + $matched = @($packages | Where-Object { $_.Name -match $pattern }) + if ($matched.Count -eq 0) { throw "Missing expected RID package matching: $pattern" } + + if ($matched.Count -gt 1) { + $names = @($matched | ForEach-Object { $_.Name }) + throw "Expected exactly one package matching $pattern (publishing a duplicate would push it twice). Found: $($names -join ', ')" + } } # The framework-dependent tool package (requires .NET 10