From 2397a7e46be5f1201b9a774b07e29764b37f41c7 Mon Sep 17 00:00:00 2001 From: Parker Bibus Date: Tue, 2 Jun 2026 15:47:19 -0700 Subject: [PATCH 1/5] =?UTF-8?q?Add=20perf-build.yml:=20per-commit=20ASP.NE?= =?UTF-8?q?T=20Core=20build=20=E2=86=92=20Build=20Cache=20Service?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new internal-only Azure DevOps pipeline that builds every commit on main and uploads per-RID ASP.NET Core runtime packs to the Build Cache Service (BCS, `pvscmdupload`) so dotnet/crank can resolve `Microsoft.AspNetCore.App` runtime binaries by commit SHA for performance-regression bisection. Modeled on dotnet/runtime's `eng/pipelines/performance/perf-build.yml` but adapted to aspnetcore's 1ES extension model (`v1/1ES.Official.PipelineTemplate.yml@1esPipelines` + `.azure/pipelines/jobs/default-build.yml@self`). Pipeline structure: * Single `build` stage with 3 jobs that reuse the existing `default-build.yml` template: `Windows_build` (x64+x86+arm64 in one job, mirroring `ci.yml`'s `Windows_build` pattern), `Linux_x64_build`, `Linux_arm64_build`. Each job runs the normal build.cmd/build.sh `--pack --all -p:OnlyPackPlatformSpecificPackages=true` invocation, then a pwsh afterBuild step packs the per-RID nupkg into a BCS-shaped archive (lowercase `microsoft.aspnetcore.app.runtime.{rid}/Release/runtimes/{rid}/...` root — the lowercase root is a load-bearing contract for the future aspnetcore-overlay crank PR, so the packer asserts the archive root after creation). * Conditional `UploadArtifacts` stage gated to `internal + (IndividualCI | Manual)` runs (Manual is included so the runbook's first seed-run can produce uploads). Five per-config jobs use the new helper template `jobs/perf-build-upload-job.yml`, one per configKey: `aspnetcore_x64_linux`, `aspnetcore_arm64_linux`, `aspnetcore_x64_windows`, `aspnetcore_arm64_windows`, `aspnetcore_x86_windows`. Per-config upload-job atomicity invariant (load-bearing): a single `AzureCLI@2` task does, in order, (1) `az storage blob upload` the archive to `builds/aspnetcore/buildArtifacts/{sha}/{configKey}/{archiveFile}`, (2) HEAD-verify the static-website URL with a short retry loop (defaults: 6 attempts × 10 s, both tunable via pipeline parameters `headRetryCount` / `headRetryDelaySeconds` so post-launch CDN visibility tuning is a YAML edit), and (3) ONLY IF (2) succeeded, upload `{"buildId":"..."}` to `buildInfo.json`. Any failure throws, fails the job (`continueOnError: false`, the default), which marks the overall AzDO build `PartiallySucceeded` and causes the MissingBuildsTrigger Azure Function to skip indexing this SHA entirely (whole-pipeline-indexing contract — operator retries the whole build green). Scope: this commit only adds the aspnetcore-side YAML on this fork/branch. The three companion changes — dotnet-performance `repoName` leaf-template PR, dotnet-performance-infra schema PR, and dotnet-performance-infra `MissingBuildsTriggerAspNetCore` Function App fork PR — are tracked separately and will be drafted later. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../pipelines/jobs/perf-build-upload-job.yml | 137 +++++ .azure/pipelines/perf-build.yml | 545 ++++++++++++++++++ 2 files changed, 682 insertions(+) create mode 100644 .azure/pipelines/jobs/perf-build-upload-job.yml create mode 100644 .azure/pipelines/perf-build.yml diff --git a/.azure/pipelines/jobs/perf-build-upload-job.yml b/.azure/pipelines/jobs/perf-build-upload-job.yml new file mode 100644 index 000000000000..93342493cbec --- /dev/null +++ b/.azure/pipelines/jobs/perf-build-upload-job.yml @@ -0,0 +1,137 @@ +# perf-build-upload-job.yml +# +# Per-config upload job for the ASP.NET Core perf-build pipeline. +# Atomicity unit for the Build Cache Service (BCS) — performs three +# operations in a single job, in this order: +# +# 1. az storage blob upload -> builds/aspnetcore/buildArtifacts/{sha}/{configKey}/{archiveFile} +# 2. HEAD-verify the static-website URL with a short retry loop +# (mitigates the CDN visibility lag the dotnet/crank PR 878 +# live-testing surfaced — Azure Storage static-website endpoints +# report uploaded blobs only after a short propagation delay). +# 3. Only if (2) succeeded: write {"buildId":"$(Build.BuildId)"} to +# builds/aspnetcore/buildArtifacts/{sha}/{configKey}/buildInfo.json +# +# Atomicity rule (load-bearing): never write buildInfo.json before the +# matching archive is HEAD-verified. This ensures the MissingBuildsTrigger +# Azure Function never indexes a (sha, configKey) before its archive is +# GET-able from the crank consumer's perspective. +# +# Whole-pipeline indexing contract (MissingBuildsTrigger.cs:64): the +# Function only indexes Azure DevOps builds with overall +# Status == Succeeded. This job MUST run with continueOnError: false (the +# default) so that any single upload failure marks the overall build +# PartiallySucceeded and the Function skips indexing for that SHA +# entirely. Operator retries the whole build green. + +parameters: +- name: configKey + type: string +- name: pipelineArtifactName + type: string +- name: archiveFile + type: string +- name: headRetryCount + type: number + default: 6 +- name: headRetryDelaySeconds + type: number + default: 10 + +jobs: +- job: Upload_${{ parameters.configKey }} + displayName: 'Upload ${{ parameters.configKey }}' + pool: + name: $(DncEngInternalBuildPool) + image: 1es-ubuntu-2204 + os: linux + steps: + - checkout: none + + - download: current + displayName: 'Download pipeline artifact ${{ parameters.pipelineArtifactName }}' + artifact: ${{ parameters.pipelineArtifactName }} + + - task: AzureCLI@2 + displayName: 'Upload archive, HEAD-verify, then write buildInfo.json' + inputs: + azureSubscription: $(_aspNetCoreBcsSubscription) + scriptType: pscore + scriptLocation: inlineScript + inlineScript: | + $ErrorActionPreference = 'Stop' + + $configKey = '${{ parameters.configKey }}' + $archiveFile = '${{ parameters.archiveFile }}' + $artifactDir = '${{ parameters.pipelineArtifactName }}' + $sha = '$(Build.SourceVersion)' + $maxAttempts = ${{ parameters.headRetryCount }} + $delaySec = ${{ parameters.headRetryDelaySeconds }} + + $blobBase = "builds/$(_aspNetCoreBcsRepoName)/buildArtifacts/$sha/$configKey" + $localArchive = "$(Pipeline.Workspace)/$artifactDir/$archiveFile" + $publicUrl = "$(_aspNetCoreBcsBaseUrl)/$blobBase/$archiveFile" + + if (-not (Test-Path $localArchive)) { + throw "Expected downloaded archive at '$localArchive' but it does not exist." + } + + Write-Host "=== Step 1/3: upload archive ===" + Write-Host "Local : $localArchive" + Write-Host "Remote: $blobBase/$archiveFile" + az storage blob upload ` + --auth-mode login ` + --account-name '$(_aspNetCoreBcsAccountName)' ` + --container-name '$(_aspNetCoreBcsContainer)' ` + --file $localArchive ` + --name "$blobBase/$archiveFile" ` + --overwrite true + if ($LASTEXITCODE -ne 0) { + throw "az storage blob upload failed for archive '$archiveFile' (exit $LASTEXITCODE)." + } + + Write-Host "" + Write-Host "=== Step 2/3: HEAD-verify static-website URL ===" + Write-Host "URL: $publicUrl" + Write-Host "Up to $maxAttempts attempts, $delaySec seconds between attempts." + $ok = $false + for ($i = 1; $i -le $maxAttempts; $i++) { + Write-Host "" + Write-Host "Attempt $i / $maxAttempts ..." + try { + $resp = Invoke-WebRequest -Uri $publicUrl -Method Head -UseBasicParsing -TimeoutSec 30 -ErrorAction Stop + Write-Host "HTTP $($resp.StatusCode)" + if ($resp.StatusCode -eq 200) { + $ok = $true + break + } + } catch { + Write-Host "Failed: $($_.Exception.Message)" + } + if ($i -lt $maxAttempts) { + Start-Sleep -Seconds $delaySec + } + } + if (-not $ok) { + throw "HEAD-verify failed for '$publicUrl' after $maxAttempts attempts. NOT writing buildInfo.json (preserves atomicity invariant)." + } + Write-Host "HEAD-verify succeeded." + + Write-Host "" + Write-Host "=== Step 3/3: write buildInfo.json ===" + $buildInfo = '{"buildId":"$(Build.BuildId)"}' + Write-Host "Remote: $blobBase/buildInfo.json" + Write-Host "Body : $buildInfo" + az storage blob upload ` + --auth-mode login ` + --account-name '$(_aspNetCoreBcsAccountName)' ` + --container-name '$(_aspNetCoreBcsContainer)' ` + --data $buildInfo ` + --name "$blobBase/buildInfo.json" ` + --overwrite true + if ($LASTEXITCODE -ne 0) { + throw "az storage blob upload failed for buildInfo.json (exit $LASTEXITCODE)." + } + + Write-Host "" + Write-Host "Done. BCS now has both archive and buildInfo.json for sha=$sha configKey=$configKey." diff --git a/.azure/pipelines/perf-build.yml b/.azure/pipelines/perf-build.yml new file mode 100644 index 000000000000..a09ea0fcdd9c --- /dev/null +++ b/.azure/pipelines/perf-build.yml @@ -0,0 +1,545 @@ +# +# perf-build.yml +# +# Azure DevOps pipeline that builds every commit on `main` (and any other +# branches listed in the trigger) and uploads per-RID ASP.NET Core runtime +# packs to the Build Cache Service (BCS) so that dotnet/crank can resolve +# Microsoft.AspNetCore.App runtime binaries by commit SHA for +# performance-regression bisection. +# +# Modeled on dotnet/runtime's `eng/pipelines/performance/perf-build.yml`, +# but with two key behavioural differences (see the perf-build plan in the +# session-state notes for the full rationale): +# +# * No RegisterBuild stage. `buildInfo.json` is written inline at the +# end of each per-config UploadArtifacts job, only after the matching +# archive has been HEAD-verified at the static-website URL. This +# enforces the BCS atomicity rule: never publish a `buildInfo.json` +# for a (sha, configKey) before its archive is GET-able. +# +# * All UploadArtifacts jobs run with `continueOnError: false` (the +# default). The MissingBuildsTrigger Azure Function only indexes +# Azure DevOps builds with overall Status == Succeeded; any single +# upload failure marks the overall build PartiallySucceeded and the +# Function skips the entire build. Operator retries the whole build +# green. The atomic unit for indexing is the AzDO build, not the +# per-config job. +# +# Output layout in BCS: +# +# builds/aspnetcore/buildArtifacts/{sha}/{configKey}/{archiveFile} +# builds/aspnetcore/buildArtifacts/{sha}/{configKey}/buildInfo.json +# +# Archive contents (lowercase directory names are a load-bearing contract +# — the future aspnetcore-overlay crank PR will FindDirectory on exactly +# `microsoft.aspnetcore.app.runtime.{rid}`): +# +# microsoft.aspnetcore.app.runtime.{rid}/ +# Release/ +# runtimes/{rid}/ +# lib/net{X}.0/Microsoft.AspNetCore.*.dll +# native/aspnetcorev2*.dll (Windows only — ANCM/IIS bits) +# + +# Build every individual commit (no batching). The MissingBuildsTrigger +# Azure Function then indexes succeeded builds into +# builds/aspnetcore/latest/{branch}/latestBuilds.json on its 8h tick. +trigger: + batch: false + branches: + include: + - main + +# No PR validation in v1. This pipeline only produces meaningful output +# under internal+IndividualCI (real commits to mirrored branches). PR +# triggering is left for a follow-up once the security model around the +# `.NET Performance` service connection is settled. +pr: none + +parameters: +- name: headRetryCount + displayName: 'HEAD-verify: maximum attempts per upload' + type: number + default: 6 +- name: headRetryDelaySeconds + displayName: 'HEAD-verify: delay (seconds) between attempts' + type: number + default: 10 + +variables: +- name: _TeamName + value: AspNetCore + +# Build Cache Service (BCS) coordinates. The static-website endpoint +# (`z22.web.core.windows.net`) is the read path crank uses, and is fronted +# by a CDN — see the HEAD-verify loop in jobs/perf-build-upload-job.yml. +- name: _aspNetCoreBcsBaseUrl + value: https://pvscmdupload.z22.web.core.windows.net +- name: _aspNetCoreBcsAccountName + value: pvscmdupload +- name: _aspNetCoreBcsContainer + value: '$web' +- name: _aspNetCoreBcsRepoName + value: aspnetcore +- name: _aspNetCoreBcsSubscription + value: '.NET Performance (790c4451-dad9-4fda-af8b-10bd9ca328fa)' + +# Build-script arguments mirrored from ci.yml. We deliberately omit +# anything related to signing, installers, helix, or maestro publishing +# because none of those contribute to the per-RID runtime pack we ship to +# BCS. +- name: _BuildArgs + value: /p:TeamName=$(_TeamName) + /p:OfficialBuildId=$(Build.BuildNumber) + /p:SkipTestBuild=true +- name: _InternalRuntimeDownloadArgs + value: -RuntimeSourceFeed https://ci.dot.net/internal + -RuntimeSourceFeedKey $(dotnetbuilds-internal-container-read-token-base64) + /p:DotNetAssetRootAccessTokenSuffix='$(dotnetbuilds-internal-container-read-token-base64)' + +- template: /eng/common/templates-official/variables/pool-providers.yml@self + +resources: + repositories: + - repository: 1esPipelines + type: git + name: 1ESPipelineTemplates/1ESPipelineTemplates + ref: refs/tags/release + +extends: + template: v1/1ES.Official.PipelineTemplate.yml@1esPipelines + parameters: + settings: + networkIsolationPolicy: Permissive,CFSClean2 + sdl: + sourceAnalysisPool: + name: NetCore1ESPool-Svc-Internal + image: windows.vs2022.amd64 + os: windows + spotBugs: + enabled: false + policheck: + enabled: true + tsa: + enabled: true + + stages: + - stage: build + displayName: Build + jobs: + + # ===================================================================== + # Windows multi-arch build job + # + # Mirrors ci.yml's Windows_build pattern: build.cmd is invoked three + # times in a single agent — x64 first (with native), then x86 and + # arm64 with -noBuildNative because they cross-pack against the x64 + # native bits. Each invocation emits its own per-RID + # Microsoft.AspNetCore.App.Runtime.win-{arch}.{ver}.nupkg into the + # shared artifacts/packages/Release/Shipping/ directory. + # + # The afterBuild step then unpacks each nupkg into the lowercase + # directory layout the future aspnetcore-overlay crank PR expects. + # ===================================================================== + - template: .azure/pipelines/jobs/default-build.yml@self + parameters: + jobName: Windows_build + jobDisplayName: 'Build: Windows x64/x86/arm64 (perf-build)' + agentOs: Windows + installNodeJs: false + + steps: + - script: ./eng/build.cmd + -ci + -prepareMachine + -nativeToolsOnMachine + -arch x64 + -pack + -all + -noBuildJava + /p:OnlyPackPlatformSpecificPackages=true + $(_BuildArgs) + $(_InternalRuntimeDownloadArgs) + /bl:artifacts/log/Release/Build.x64.binlog + env: + MSBUILDUSESERVER: "1" + displayName: Build x64 + + - script: ./eng/build.cmd + -ci + -prepareMachine + -arch x86 + -pack + -all + -noBuildJava + -noBuildNative + /p:OnlyPackPlatformSpecificPackages=true + $(_BuildArgs) + $(_InternalRuntimeDownloadArgs) + -ExcludeCIBinaryLog + env: + MSBUILDUSESERVER: "1" + displayName: Build x86 + + - script: ./eng/build.cmd + -ci + -prepareMachine + -arch arm64 + -pack + -noBuildJava + -noBuildNative + /p:OnlyPackPlatformSpecificPackages=true + $(_BuildArgs) + $(_InternalRuntimeDownloadArgs) + /bl:artifacts/log/Release/Build.arm64.binlog + env: + MSBUILDUSESERVER: "1" + displayName: Build ARM64 + + afterBuild: + - powershell: | + $ErrorActionPreference = 'Stop' + + $shippingDir = Join-Path $env:BUILD_SOURCESDIRECTORY 'artifacts/packages/Release/Shipping' + $stagingRoot = Join-Path $env:BUILD_ARTIFACTSTAGINGDIRECTORY 'bcs' + + # Map: RID -> {os, arch, archive base name}. + $rids = @( + @{ rid='win-x64'; os='windows'; arch='x64'; }, + @{ rid='win-x86'; os='windows'; arch='x86'; }, + @{ rid='win-arm64'; os='windows'; arch='arm64'; } + ) + + foreach ($cfg in $rids) { + $rid = $cfg.rid + $os = $cfg.os + $arch = $cfg.arch + $configKey = "aspnetcore_${arch}_${os}" + $artifactName = "BuildArtifacts_${os}_${arch}_Release_aspnetcore" + $archiveFile = "${artifactName}.zip" + + Write-Host "" + Write-Host "=== Packing $configKey (rid=$rid) ===" + + $pattern = "Microsoft.AspNetCore.App.Runtime.$rid.*.nupkg" + $nupkg = Get-ChildItem -Path $shippingDir -Filter $pattern -File -ErrorAction SilentlyContinue | + Where-Object { $_.Name -notmatch '\.symbols\.nupkg$' } | + Sort-Object Name | + Select-Object -First 1 + if (-not $nupkg) { + throw "Could not find runtime pack nupkg matching '$pattern' under '$shippingDir'." + } + Write-Host "Found nupkg: $($nupkg.FullName)" + + # microsoft.aspnetcore.app.runtime.{rid}/Release/ <-- archive root (lowercase!) + $stageDir = Join-Path $stagingRoot $artifactName + $payloadDir = Join-Path $stageDir "microsoft.aspnetcore.app.runtime.$rid" + $releaseDir = Join-Path $payloadDir 'Release' + New-Item -ItemType Directory -Force -Path $releaseDir | Out-Null + + # Extract the nupkg (it's a zip) into Release/. + Expand-Archive -LiteralPath $nupkg.FullName -DestinationPath $releaseDir -Force + + $runtimesDir = Join-Path $releaseDir "runtimes/$rid" + if (-not (Test-Path $runtimesDir)) { + throw "Extracted runtime pack is missing expected directory '$runtimesDir'." + } + + # Sanity-check that managed assemblies landed where we expect. + $libMatches = Get-ChildItem -Path (Join-Path $runtimesDir 'lib') -Recurse -Filter 'Microsoft.AspNetCore.*.dll' -ErrorAction SilentlyContinue + if (-not $libMatches -or $libMatches.Count -eq 0) { + throw "Extracted runtime pack for $rid is missing managed Microsoft.AspNetCore.*.dll under 'runtimes/$rid/lib'." + } + Write-Host "Validated managed lib dir contains $($libMatches.Count) Microsoft.AspNetCore.*.dll(s)." + + # Defensive: strip debug-symbol files that occasionally tag along. + Get-ChildItem -Path $runtimesDir -Recurse -Include '*.pdb','*.dbg' -ErrorAction SilentlyContinue | + ForEach-Object { Remove-Item -LiteralPath $_.FullName -Force } + + # Zip the lowercase root directory (so the archive contents start + # with `microsoft.aspnetcore.app.runtime.{rid}/...`). + $archivePath = Join-Path $stageDir $archiveFile + if (Test-Path $archivePath) { Remove-Item -LiteralPath $archivePath -Force } + Compress-Archive -Path $payloadDir -DestinationPath $archivePath -CompressionLevel Optimal -Force + + # Assert the archive root matches the load-bearing contract. + Add-Type -AssemblyName System.IO.Compression.FileSystem + $zip = [System.IO.Compression.ZipFile]::OpenRead($archivePath) + try { + $expectedRoot = "microsoft.aspnetcore.app.runtime.$rid/" + $rootEntries = $zip.Entries | ForEach-Object { ($_.FullName -split '/')[0] } | Sort-Object -Unique + if ($rootEntries.Count -ne 1 -or "$($rootEntries[0])/" -ne $expectedRoot) { + throw "Archive '$archivePath' has root entries [$($rootEntries -join ', ')] but expected exactly '$expectedRoot' (load-bearing contract for future crank PR)." + } + } finally { + $zip.Dispose() + } + + # Drop the working payload directory so only the archive ends up in the published pipeline artifact. + Remove-Item -LiteralPath $payloadDir -Recurse -Force + + $size = (Get-Item $archivePath).Length + Write-Host "Produced archive: $archivePath ($size bytes)" + } + displayName: 'Pack BCS archives (win-x64 / win-x86 / win-arm64)' + + artifacts: + - name: BuildArtifacts_windows_x64_Release_aspnetcore + path: $(Build.ArtifactStagingDirectory)/bcs/BuildArtifacts_windows_x64_Release_aspnetcore + - name: BuildArtifacts_windows_x86_Release_aspnetcore + path: $(Build.ArtifactStagingDirectory)/bcs/BuildArtifacts_windows_x86_Release_aspnetcore + - name: BuildArtifacts_windows_arm64_Release_aspnetcore + path: $(Build.ArtifactStagingDirectory)/bcs/BuildArtifacts_windows_arm64_Release_aspnetcore + - name: Windows_perf_build_Logs_Attempt_$(System.JobAttempt) + path: artifacts/log/ + publishOnError: true + includeForks: true + + # ===================================================================== + # Linux x64 build job + # ===================================================================== + - template: .azure/pipelines/jobs/default-build.yml@self + parameters: + jobName: Linux_x64_build + jobDisplayName: 'Build: Linux x64 (perf-build)' + agentOs: Linux + use1ESUbuntu: true + installNodeJs: false + buildArgs: + --arch x64 + --pack + --all + --no-build-java + -p:OnlyPackPlatformSpecificPackages=true + $(_BuildArgs) + $(_InternalRuntimeDownloadArgs) + afterBuild: + - pwsh: | + $ErrorActionPreference = 'Stop' + + $shippingDir = Join-Path $env:BUILD_SOURCESDIRECTORY 'artifacts/packages/Release/Shipping' + $stagingRoot = Join-Path $env:BUILD_ARTIFACTSTAGINGDIRECTORY 'bcs' + + $rid = 'linux-x64' + $os = 'linux' + $arch = 'x64' + $configKey = "aspnetcore_${arch}_${os}" + $artifactName = "BuildArtifacts_${os}_${arch}_Release_aspnetcore" + $archiveFile = "${artifactName}.tar.gz" + + Write-Host "=== Packing $configKey (rid=$rid) ===" + + $pattern = "Microsoft.AspNetCore.App.Runtime.$rid.*.nupkg" + $nupkg = Get-ChildItem -Path $shippingDir -Filter $pattern -File -ErrorAction SilentlyContinue | + Where-Object { $_.Name -notmatch '\.symbols\.nupkg$' } | + Sort-Object Name | + Select-Object -First 1 + if (-not $nupkg) { + throw "Could not find runtime pack nupkg matching '$pattern' under '$shippingDir'." + } + Write-Host "Found nupkg: $($nupkg.FullName)" + + $stageDir = Join-Path $stagingRoot $artifactName + $payloadDir = Join-Path $stageDir "microsoft.aspnetcore.app.runtime.$rid" + $releaseDir = Join-Path $payloadDir 'Release' + New-Item -ItemType Directory -Force -Path $releaseDir | Out-Null + + # `unzip` is present on the 1ES Ubuntu agents. + & unzip -q $nupkg.FullName -d $releaseDir + if ($LASTEXITCODE -ne 0) { throw "unzip of '$($nupkg.FullName)' failed (exit $LASTEXITCODE)." } + + $runtimesDir = Join-Path $releaseDir "runtimes/$rid" + if (-not (Test-Path $runtimesDir)) { + throw "Extracted runtime pack is missing expected directory '$runtimesDir'." + } + + $libMatches = Get-ChildItem -Path (Join-Path $runtimesDir 'lib') -Recurse -Filter 'Microsoft.AspNetCore.*.dll' -ErrorAction SilentlyContinue + if (-not $libMatches -or $libMatches.Count -eq 0) { + throw "Extracted runtime pack for $rid is missing managed Microsoft.AspNetCore.*.dll under 'runtimes/$rid/lib'." + } + Write-Host "Validated managed lib dir contains $($libMatches.Count) Microsoft.AspNetCore.*.dll(s)." + + Get-ChildItem -Path $runtimesDir -Recurse -Include '*.pdb','*.dbg' -ErrorAction SilentlyContinue | + ForEach-Object { Remove-Item -LiteralPath $_.FullName -Force } + + $archivePath = Join-Path $stageDir $archiveFile + if (Test-Path $archivePath) { Remove-Item -LiteralPath $archivePath -Force } + # Tar the lowercase root directory so the archive contents start with + # `microsoft.aspnetcore.app.runtime.{rid}/...`. + & tar -czf $archivePath -C $stageDir "microsoft.aspnetcore.app.runtime.$rid" + if ($LASTEXITCODE -ne 0) { throw "tar of '$payloadDir' failed (exit $LASTEXITCODE)." } + + # Assert the archive root matches the load-bearing contract. + $expectedRoot = "microsoft.aspnetcore.app.runtime.$rid/" + $firstEntry = (& tar -tzf $archivePath | Select-Object -First 1) + if (-not $firstEntry.StartsWith($expectedRoot)) { + throw "Archive '$archivePath' first entry '$firstEntry' does not start with '$expectedRoot' (load-bearing contract for future crank PR)." + } + + # Drop the working payload directory so only the archive ends up in the published pipeline artifact. + Remove-Item -LiteralPath $payloadDir -Recurse -Force + + $size = (Get-Item $archivePath).Length + Write-Host "Produced archive: $archivePath ($size bytes)" + displayName: 'Pack BCS archive (linux-x64)' + + artifacts: + - name: BuildArtifacts_linux_x64_Release_aspnetcore + path: $(Build.ArtifactStagingDirectory)/bcs/BuildArtifacts_linux_x64_Release_aspnetcore + - name: Linux_x64_perf_build_Logs_Attempt_$(System.JobAttempt) + path: artifacts/log/ + publishOnError: true + includeForks: true + + # ===================================================================== + # Linux arm64 build job (cross-build from x64 host) + # ===================================================================== + - template: .azure/pipelines/jobs/default-build.yml@self + parameters: + jobName: Linux_arm64_build + jobDisplayName: 'Build: Linux ARM64 (perf-build)' + agentOs: Linux + use1ESUbuntu: true + installNodeJs: false + buildArgs: + --arch arm64 + --pack + --all + --no-build-java + -p:OnlyPackPlatformSpecificPackages=true + $(_BuildArgs) + $(_InternalRuntimeDownloadArgs) + afterBuild: + - pwsh: | + $ErrorActionPreference = 'Stop' + + $shippingDir = Join-Path $env:BUILD_SOURCESDIRECTORY 'artifacts/packages/Release/Shipping' + $stagingRoot = Join-Path $env:BUILD_ARTIFACTSTAGINGDIRECTORY 'bcs' + + $rid = 'linux-arm64' + $os = 'linux' + $arch = 'arm64' + $configKey = "aspnetcore_${arch}_${os}" + $artifactName = "BuildArtifacts_${os}_${arch}_Release_aspnetcore" + $archiveFile = "${artifactName}.tar.gz" + + Write-Host "=== Packing $configKey (rid=$rid) ===" + + $pattern = "Microsoft.AspNetCore.App.Runtime.$rid.*.nupkg" + $nupkg = Get-ChildItem -Path $shippingDir -Filter $pattern -File -ErrorAction SilentlyContinue | + Where-Object { $_.Name -notmatch '\.symbols\.nupkg$' } | + Sort-Object Name | + Select-Object -First 1 + if (-not $nupkg) { + throw "Could not find runtime pack nupkg matching '$pattern' under '$shippingDir'." + } + Write-Host "Found nupkg: $($nupkg.FullName)" + + $stageDir = Join-Path $stagingRoot $artifactName + $payloadDir = Join-Path $stageDir "microsoft.aspnetcore.app.runtime.$rid" + $releaseDir = Join-Path $payloadDir 'Release' + New-Item -ItemType Directory -Force -Path $releaseDir | Out-Null + + & unzip -q $nupkg.FullName -d $releaseDir + if ($LASTEXITCODE -ne 0) { throw "unzip of '$($nupkg.FullName)' failed (exit $LASTEXITCODE)." } + + $runtimesDir = Join-Path $releaseDir "runtimes/$rid" + if (-not (Test-Path $runtimesDir)) { + throw "Extracted runtime pack is missing expected directory '$runtimesDir'." + } + + $libMatches = Get-ChildItem -Path (Join-Path $runtimesDir 'lib') -Recurse -Filter 'Microsoft.AspNetCore.*.dll' -ErrorAction SilentlyContinue + if (-not $libMatches -or $libMatches.Count -eq 0) { + throw "Extracted runtime pack for $rid is missing managed Microsoft.AspNetCore.*.dll under 'runtimes/$rid/lib'." + } + Write-Host "Validated managed lib dir contains $($libMatches.Count) Microsoft.AspNetCore.*.dll(s)." + + Get-ChildItem -Path $runtimesDir -Recurse -Include '*.pdb','*.dbg' -ErrorAction SilentlyContinue | + ForEach-Object { Remove-Item -LiteralPath $_.FullName -Force } + + $archivePath = Join-Path $stageDir $archiveFile + if (Test-Path $archivePath) { Remove-Item -LiteralPath $archivePath -Force } + & tar -czf $archivePath -C $stageDir "microsoft.aspnetcore.app.runtime.$rid" + if ($LASTEXITCODE -ne 0) { throw "tar of '$payloadDir' failed (exit $LASTEXITCODE)." } + + # Assert the archive root matches the load-bearing contract. + $expectedRoot = "microsoft.aspnetcore.app.runtime.$rid/" + $firstEntry = (& tar -tzf $archivePath | Select-Object -First 1) + if (-not $firstEntry.StartsWith($expectedRoot)) { + throw "Archive '$archivePath' first entry '$firstEntry' does not start with '$expectedRoot' (load-bearing contract for future crank PR)." + } + + # Drop the working payload directory so only the archive ends up in the published pipeline artifact. + Remove-Item -LiteralPath $payloadDir -Recurse -Force + + $size = (Get-Item $archivePath).Length + Write-Host "Produced archive: $archivePath ($size bytes)" + displayName: 'Pack BCS archive (linux-arm64)' + + artifacts: + - name: BuildArtifacts_linux_arm64_Release_aspnetcore + path: $(Build.ArtifactStagingDirectory)/bcs/BuildArtifacts_linux_arm64_Release_aspnetcore + - name: Linux_arm64_perf_build_Logs_Attempt_$(System.JobAttempt) + path: artifacts/log/ + publishOnError: true + includeForks: true + + # ========================================================================== + # UploadArtifacts stage + # + # Gated to internal + (IndividualCI | Manual). IndividualCI covers the + # steady-state per-commit flow; Manual covers the runbook's "queue one + # seed run" step (so a manually queued run actually uploads, instead of + # silently producing a successful AzDO build with zero BCS payloads that + # the MissingBuildsTrigger Function would later try to index). + # `condition: succeeded()` then means any build job failure skips this + # stage entirely, which makes the overall AzDO build report as + # PartiallySucceeded and the Function skips indexing for this SHA + # (whole-pipeline-indexing contract). + # ========================================================================== + - ${{ if and(ne(variables['System.TeamProject'], 'public'), or(eq(variables['Build.Reason'], 'IndividualCI'), eq(variables['Build.Reason'], 'Manual'))) }}: + - stage: UploadArtifacts + displayName: 'Upload Artifacts to BCS' + dependsOn: build + condition: succeeded() + jobs: + + - template: .azure/pipelines/jobs/perf-build-upload-job.yml@self + parameters: + configKey: aspnetcore_x64_linux + pipelineArtifactName: BuildArtifacts_linux_x64_Release_aspnetcore + archiveFile: BuildArtifacts_linux_x64_Release_aspnetcore.tar.gz + headRetryCount: ${{ parameters.headRetryCount }} + headRetryDelaySeconds: ${{ parameters.headRetryDelaySeconds }} + + - template: .azure/pipelines/jobs/perf-build-upload-job.yml@self + parameters: + configKey: aspnetcore_arm64_linux + pipelineArtifactName: BuildArtifacts_linux_arm64_Release_aspnetcore + archiveFile: BuildArtifacts_linux_arm64_Release_aspnetcore.tar.gz + headRetryCount: ${{ parameters.headRetryCount }} + headRetryDelaySeconds: ${{ parameters.headRetryDelaySeconds }} + + - template: .azure/pipelines/jobs/perf-build-upload-job.yml@self + parameters: + configKey: aspnetcore_x64_windows + pipelineArtifactName: BuildArtifacts_windows_x64_Release_aspnetcore + archiveFile: BuildArtifacts_windows_x64_Release_aspnetcore.zip + headRetryCount: ${{ parameters.headRetryCount }} + headRetryDelaySeconds: ${{ parameters.headRetryDelaySeconds }} + + - template: .azure/pipelines/jobs/perf-build-upload-job.yml@self + parameters: + configKey: aspnetcore_arm64_windows + pipelineArtifactName: BuildArtifacts_windows_arm64_Release_aspnetcore + archiveFile: BuildArtifacts_windows_arm64_Release_aspnetcore.zip + headRetryCount: ${{ parameters.headRetryCount }} + headRetryDelaySeconds: ${{ parameters.headRetryDelaySeconds }} + + - template: .azure/pipelines/jobs/perf-build-upload-job.yml@self + parameters: + configKey: aspnetcore_x86_windows + pipelineArtifactName: BuildArtifacts_windows_x86_Release_aspnetcore + archiveFile: BuildArtifacts_windows_x86_Release_aspnetcore.zip + headRetryCount: ${{ parameters.headRetryCount }} + headRetryDelaySeconds: ${{ parameters.headRetryDelaySeconds }} From d2e19a87706861aa15266ffbd9377a83b0ae4fac Mon Sep 17 00:00:00 2001 From: Parker Bibus Date: Thu, 11 Jun 2026 15:37:41 -0700 Subject: [PATCH 2/5] Delegate BCS upload to shared dotnet/performance template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the inline .azure/pipelines/jobs/perf-build-upload-job.yml (deleted) with a single call to dotnet/performance's generalized upload-build-artifacts-jobs.yml dispatcher (the same template runtime's perf-build.yml uses). Companion change in dotnet/performance parameterizes the dispatcher + leaf templates with repoName and pool*. * Adds 'performance = internal/dotnet-performance' to resources.repositories. * Drops the headRetryCount/headRetryDelaySeconds parameters and the _aspNetCoreBcs* variables — the shared template handles account name, container, base URL, and subscription internally and runtime's pipeline has shipped without an explicit HEAD-verify step from the start. The MissingBuildsTrigger Function uses the AzDO build's overall Status == Succeeded as the sole atomicity gate. * Replaces the five inline perf-build-upload-job.yml@self template calls with a single upload-build-artifacts-jobs.yml@performance dispatch passing buildType: [aspnetcore_x64_linux, aspnetcore_arm64_linux, aspnetcore_x64_windows, aspnetcore_arm64_windows, aspnetcore_x86_windows], repoName: aspnetcore, and the dnceng internal 1ES Ubuntu pool (required because 1ES.Official mandates every job declare a pool). Net diff: 142 lines of perf-build.yml edited, perf-build-upload-job.yml (137 lines) deleted. Build stage unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../pipelines/jobs/perf-build-upload-job.yml | 137 ----------------- .azure/pipelines/perf-build.yml | 142 +++++++----------- 2 files changed, 56 insertions(+), 223 deletions(-) delete mode 100644 .azure/pipelines/jobs/perf-build-upload-job.yml diff --git a/.azure/pipelines/jobs/perf-build-upload-job.yml b/.azure/pipelines/jobs/perf-build-upload-job.yml deleted file mode 100644 index 93342493cbec..000000000000 --- a/.azure/pipelines/jobs/perf-build-upload-job.yml +++ /dev/null @@ -1,137 +0,0 @@ -# perf-build-upload-job.yml -# -# Per-config upload job for the ASP.NET Core perf-build pipeline. -# Atomicity unit for the Build Cache Service (BCS) — performs three -# operations in a single job, in this order: -# -# 1. az storage blob upload -> builds/aspnetcore/buildArtifacts/{sha}/{configKey}/{archiveFile} -# 2. HEAD-verify the static-website URL with a short retry loop -# (mitigates the CDN visibility lag the dotnet/crank PR 878 -# live-testing surfaced — Azure Storage static-website endpoints -# report uploaded blobs only after a short propagation delay). -# 3. Only if (2) succeeded: write {"buildId":"$(Build.BuildId)"} to -# builds/aspnetcore/buildArtifacts/{sha}/{configKey}/buildInfo.json -# -# Atomicity rule (load-bearing): never write buildInfo.json before the -# matching archive is HEAD-verified. This ensures the MissingBuildsTrigger -# Azure Function never indexes a (sha, configKey) before its archive is -# GET-able from the crank consumer's perspective. -# -# Whole-pipeline indexing contract (MissingBuildsTrigger.cs:64): the -# Function only indexes Azure DevOps builds with overall -# Status == Succeeded. This job MUST run with continueOnError: false (the -# default) so that any single upload failure marks the overall build -# PartiallySucceeded and the Function skips indexing for that SHA -# entirely. Operator retries the whole build green. - -parameters: -- name: configKey - type: string -- name: pipelineArtifactName - type: string -- name: archiveFile - type: string -- name: headRetryCount - type: number - default: 6 -- name: headRetryDelaySeconds - type: number - default: 10 - -jobs: -- job: Upload_${{ parameters.configKey }} - displayName: 'Upload ${{ parameters.configKey }}' - pool: - name: $(DncEngInternalBuildPool) - image: 1es-ubuntu-2204 - os: linux - steps: - - checkout: none - - - download: current - displayName: 'Download pipeline artifact ${{ parameters.pipelineArtifactName }}' - artifact: ${{ parameters.pipelineArtifactName }} - - - task: AzureCLI@2 - displayName: 'Upload archive, HEAD-verify, then write buildInfo.json' - inputs: - azureSubscription: $(_aspNetCoreBcsSubscription) - scriptType: pscore - scriptLocation: inlineScript - inlineScript: | - $ErrorActionPreference = 'Stop' - - $configKey = '${{ parameters.configKey }}' - $archiveFile = '${{ parameters.archiveFile }}' - $artifactDir = '${{ parameters.pipelineArtifactName }}' - $sha = '$(Build.SourceVersion)' - $maxAttempts = ${{ parameters.headRetryCount }} - $delaySec = ${{ parameters.headRetryDelaySeconds }} - - $blobBase = "builds/$(_aspNetCoreBcsRepoName)/buildArtifacts/$sha/$configKey" - $localArchive = "$(Pipeline.Workspace)/$artifactDir/$archiveFile" - $publicUrl = "$(_aspNetCoreBcsBaseUrl)/$blobBase/$archiveFile" - - if (-not (Test-Path $localArchive)) { - throw "Expected downloaded archive at '$localArchive' but it does not exist." - } - - Write-Host "=== Step 1/3: upload archive ===" - Write-Host "Local : $localArchive" - Write-Host "Remote: $blobBase/$archiveFile" - az storage blob upload ` - --auth-mode login ` - --account-name '$(_aspNetCoreBcsAccountName)' ` - --container-name '$(_aspNetCoreBcsContainer)' ` - --file $localArchive ` - --name "$blobBase/$archiveFile" ` - --overwrite true - if ($LASTEXITCODE -ne 0) { - throw "az storage blob upload failed for archive '$archiveFile' (exit $LASTEXITCODE)." - } - - Write-Host "" - Write-Host "=== Step 2/3: HEAD-verify static-website URL ===" - Write-Host "URL: $publicUrl" - Write-Host "Up to $maxAttempts attempts, $delaySec seconds between attempts." - $ok = $false - for ($i = 1; $i -le $maxAttempts; $i++) { - Write-Host "" - Write-Host "Attempt $i / $maxAttempts ..." - try { - $resp = Invoke-WebRequest -Uri $publicUrl -Method Head -UseBasicParsing -TimeoutSec 30 -ErrorAction Stop - Write-Host "HTTP $($resp.StatusCode)" - if ($resp.StatusCode -eq 200) { - $ok = $true - break - } - } catch { - Write-Host "Failed: $($_.Exception.Message)" - } - if ($i -lt $maxAttempts) { - Start-Sleep -Seconds $delaySec - } - } - if (-not $ok) { - throw "HEAD-verify failed for '$publicUrl' after $maxAttempts attempts. NOT writing buildInfo.json (preserves atomicity invariant)." - } - Write-Host "HEAD-verify succeeded." - - Write-Host "" - Write-Host "=== Step 3/3: write buildInfo.json ===" - $buildInfo = '{"buildId":"$(Build.BuildId)"}' - Write-Host "Remote: $blobBase/buildInfo.json" - Write-Host "Body : $buildInfo" - az storage blob upload ` - --auth-mode login ` - --account-name '$(_aspNetCoreBcsAccountName)' ` - --container-name '$(_aspNetCoreBcsContainer)' ` - --data $buildInfo ` - --name "$blobBase/buildInfo.json" ` - --overwrite true - if ($LASTEXITCODE -ne 0) { - throw "az storage blob upload failed for buildInfo.json (exit $LASTEXITCODE)." - } - - Write-Host "" - Write-Host "Done. BCS now has both archive and buildInfo.json for sha=$sha configKey=$configKey." diff --git a/.azure/pipelines/perf-build.yml b/.azure/pipelines/perf-build.yml index a09ea0fcdd9c..79ee7290085e 100644 --- a/.azure/pipelines/perf-build.yml +++ b/.azure/pipelines/perf-build.yml @@ -7,28 +7,29 @@ # Microsoft.AspNetCore.App runtime binaries by commit SHA for # performance-regression bisection. # -# Modeled on dotnet/runtime's `eng/pipelines/performance/perf-build.yml`, -# but with two key behavioural differences (see the perf-build plan in the -# session-state notes for the full rationale): +# Modeled on dotnet/runtime's `eng/pipelines/performance/perf-build.yml`: +# the per-config "upload artifact to BCS blob storage" job is the same +# template (`upload-build-artifacts-jobs.yml@performance`) the runtime +# perf-build calls. That template was generalized in dotnet/performance +# to take an explicit `repoName` so non-runtime tenants (us) land at +# `builds/aspnetcore/...` instead of `builds/runtime/...`, plus optional +# pool* parameters because 1ES.Official requires every job to declare +# a pool. # -# * No RegisterBuild stage. `buildInfo.json` is written inline at the -# end of each per-config UploadArtifacts job, only after the matching -# archive has been HEAD-verified at the static-website URL. This -# enforces the BCS atomicity rule: never publish a `buildInfo.json` -# for a (sha, configKey) before its archive is GET-able. -# -# * All UploadArtifacts jobs run with `continueOnError: false` (the -# default). The MissingBuildsTrigger Azure Function only indexes -# Azure DevOps builds with overall Status == Succeeded; any single -# upload failure marks the overall build PartiallySucceeded and the -# Function skips the entire build. Operator retries the whole build -# green. The atomic unit for indexing is the AzDO build, not the -# per-config job. +# Atomicity contract (mirrors runtime's): the MissingBuildsTrigger +# Azure Function only indexes Azure DevOps builds with overall +# Status == Succeeded. Per-config Build jobs run with the default +# `continueOnError: false`; any failure sinks the overall build to +# Failed and the Function skips indexing for that SHA. Operator retries +# the whole build green. The atomic unit for indexing is the AzDO +# build, not the per-config job. (We deliberately do not write a +# separate `buildInfo.json` marker or HEAD-verify the static-website +# URL — runtime doesn't, and the AzDO build result is already the +# correct gate.) # # Output layout in BCS: # # builds/aspnetcore/buildArtifacts/{sha}/{configKey}/{archiveFile} -# builds/aspnetcore/buildArtifacts/{sha}/{configKey}/buildInfo.json # # Archive contents (lowercase directory names are a load-bearing contract # — the future aspnetcore-overlay crank PR will FindDirectory on exactly @@ -56,34 +57,10 @@ trigger: # `.NET Performance` service connection is settled. pr: none -parameters: -- name: headRetryCount - displayName: 'HEAD-verify: maximum attempts per upload' - type: number - default: 6 -- name: headRetryDelaySeconds - displayName: 'HEAD-verify: delay (seconds) between attempts' - type: number - default: 10 - variables: - name: _TeamName value: AspNetCore -# Build Cache Service (BCS) coordinates. The static-website endpoint -# (`z22.web.core.windows.net`) is the read path crank uses, and is fronted -# by a CDN — see the HEAD-verify loop in jobs/perf-build-upload-job.yml. -- name: _aspNetCoreBcsBaseUrl - value: https://pvscmdupload.z22.web.core.windows.net -- name: _aspNetCoreBcsAccountName - value: pvscmdupload -- name: _aspNetCoreBcsContainer - value: '$web' -- name: _aspNetCoreBcsRepoName - value: aspnetcore -- name: _aspNetCoreBcsSubscription - value: '.NET Performance (790c4451-dad9-4fda-af8b-10bd9ca328fa)' - # Build-script arguments mirrored from ci.yml. We deliberately omit # anything related to signing, installers, helix, or maestro publishing # because none of those contribute to the per-RID runtime pack we ship to @@ -105,6 +82,13 @@ resources: type: git name: 1ESPipelineTemplates/1ESPipelineTemplates ref: refs/tags/release + # dotnet/performance hosts the shared BCS upload templates that runtime's + # perf-build.yml also consumes. We reference its dnceng AzDO mirror + # (internal/dotnet-performance) so the templates resolve at pipeline + # compile time. + - repository: performance + type: git + name: internal/dotnet-performance extends: template: v1/1ES.Official.PipelineTemplate.yml@1esPipelines @@ -487,15 +471,24 @@ extends: # ========================================================================== # UploadArtifacts stage # + # Delegates to dotnet/performance's `upload-build-artifacts-jobs.yml` + # dispatcher (the same template runtime's perf-build.yml calls), which + # fans out into per-configKey `templates/upload-build-artifacts-job.yml` + # invocations that each: + # + # 1. Download the matching pipeline artifact from the Build stage. + # 2. `az storage blob upload` it to: + # $web/builds/aspnetcore/buildArtifacts/{sha}/{configKey}/{file} + # # Gated to internal + (IndividualCI | Manual). IndividualCI covers the # steady-state per-commit flow; Manual covers the runbook's "queue one # seed run" step (so a manually queued run actually uploads, instead of - # silently producing a successful AzDO build with zero BCS payloads that - # the MissingBuildsTrigger Function would later try to index). - # `condition: succeeded()` then means any build job failure skips this - # stage entirely, which makes the overall AzDO build report as - # PartiallySucceeded and the Function skips indexing for this SHA - # (whole-pipeline-indexing contract). + # silently producing a successful AzDO build with zero BCS payloads). + # + # `condition: succeeded()` then means any Build-stage job failure skips + # this entire stage, which makes the overall AzDO build report as + # Failed and the MissingBuildsTrigger Function skips indexing for this + # SHA (whole-pipeline-indexing contract). # ========================================================================== - ${{ if and(ne(variables['System.TeamProject'], 'public'), or(eq(variables['Build.Reason'], 'IndividualCI'), eq(variables['Build.Reason'], 'Manual'))) }}: - stage: UploadArtifacts @@ -503,43 +496,20 @@ extends: dependsOn: build condition: succeeded() jobs: - - - template: .azure/pipelines/jobs/perf-build-upload-job.yml@self - parameters: - configKey: aspnetcore_x64_linux - pipelineArtifactName: BuildArtifacts_linux_x64_Release_aspnetcore - archiveFile: BuildArtifacts_linux_x64_Release_aspnetcore.tar.gz - headRetryCount: ${{ parameters.headRetryCount }} - headRetryDelaySeconds: ${{ parameters.headRetryDelaySeconds }} - - - template: .azure/pipelines/jobs/perf-build-upload-job.yml@self - parameters: - configKey: aspnetcore_arm64_linux - pipelineArtifactName: BuildArtifacts_linux_arm64_Release_aspnetcore - archiveFile: BuildArtifacts_linux_arm64_Release_aspnetcore.tar.gz - headRetryCount: ${{ parameters.headRetryCount }} - headRetryDelaySeconds: ${{ parameters.headRetryDelaySeconds }} - - - template: .azure/pipelines/jobs/perf-build-upload-job.yml@self - parameters: - configKey: aspnetcore_x64_windows - pipelineArtifactName: BuildArtifacts_windows_x64_Release_aspnetcore - archiveFile: BuildArtifacts_windows_x64_Release_aspnetcore.zip - headRetryCount: ${{ parameters.headRetryCount }} - headRetryDelaySeconds: ${{ parameters.headRetryDelaySeconds }} - - - template: .azure/pipelines/jobs/perf-build-upload-job.yml@self - parameters: - configKey: aspnetcore_arm64_windows - pipelineArtifactName: BuildArtifacts_windows_arm64_Release_aspnetcore - archiveFile: BuildArtifacts_windows_arm64_Release_aspnetcore.zip - headRetryCount: ${{ parameters.headRetryCount }} - headRetryDelaySeconds: ${{ parameters.headRetryDelaySeconds }} - - - template: .azure/pipelines/jobs/perf-build-upload-job.yml@self + - template: /eng/pipelines/upload-build-artifacts-jobs.yml@performance parameters: - configKey: aspnetcore_x86_windows - pipelineArtifactName: BuildArtifacts_windows_x86_Release_aspnetcore - archiveFile: BuildArtifacts_windows_x86_Release_aspnetcore.zip - headRetryCount: ${{ parameters.headRetryCount }} - headRetryDelaySeconds: ${{ parameters.headRetryDelaySeconds }} + performanceRepoAlias: performance + repoName: aspnetcore + # 1ES.Official requires every job to declare a pool, so we pass + # the dnceng internal 1ES Ubuntu pool through to each per-artifact + # upload job. (`az storage blob upload` is platform-agnostic so + # a Linux pool works for the Windows .zip archives too.) + poolName: $(DncEngInternalBuildPool) + poolImage: 1es-ubuntu-2204 + poolOs: linux + buildType: + - aspnetcore_x64_linux + - aspnetcore_arm64_linux + - aspnetcore_x64_windows + - aspnetcore_arm64_windows + - aspnetcore_x86_windows From 02865ad883e4b43dfe0cf04e7033023b8fc0a164 Mon Sep 17 00:00:00 2001 From: Parker Bibus Date: Thu, 11 Jun 2026 16:05:04 -0700 Subject: [PATCH 3/5] Drop 1ES.Official + adopt runtime's three-stage perf-build shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related changes: 1. Drop 1ES.Official extension. Per-commit perf-build artifacts go to a private BCS cache (not signed / redistributed), so SDL / PoliCheck / TSA scans add no security value here. Matches runtime's perf-build which also runs as a plain (non-1ES) pipeline against the same .NET Performance Azure service connection. Removes ~30 LOC of 1ES boilerplate (resources entry for 1esPipelines, extends block, sdl config) and lets jobs inherit the pipeline's default pool naturally. 2. Adopt runtime's three-stage shape: RegisterBuild ─┐ parallel (dependsOn: []) build ─┤ parallel │ UploadArtifacts ┘ dependsOn: [build, RegisterBuild] The new RegisterBuild stage delegates to register-build-jobs.yml@performance (with repoName: aspnetcore) which writes a tiny per-configKey buildInfo.json marker to builds/aspnetcore/buildArtifacts/{sha}/{configKey}/buildInfo.json. Matches the layout of the runtime perf-build so any future dotnet-performance-infra dashboard / cleanup tool that reads buildInfo.json works uniformly for both repos. Companion change in dotnet/performance parameterizes register-build-job.yml with repoName. UploadArtifacts now depends on both Build and RegisterBuild with condition: succeeded() — any stage failure sinks the overall AzDO build to Failed and the MissingBuildsTrigger Function skips indexing for the SHA (whole-pipeline-indexing contract preserved). Pool params previously passed to the upload dispatcher are removed (they only existed to satisfy 1ES.Official's pool requirement; without 1ES, jobs inherit the pipeline default pool). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .azure/pipelines/perf-build.yml | 816 ++++++++++++++++---------------- 1 file changed, 417 insertions(+), 399 deletions(-) diff --git a/.azure/pipelines/perf-build.yml b/.azure/pipelines/perf-build.yml index 79ee7290085e..bb729ecd8d19 100644 --- a/.azure/pipelines/perf-build.yml +++ b/.azure/pipelines/perf-build.yml @@ -7,28 +7,41 @@ # Microsoft.AspNetCore.App runtime binaries by commit SHA for # performance-regression bisection. # -# Modeled on dotnet/runtime's `eng/pipelines/performance/perf-build.yml`: -# the per-config "upload artifact to BCS blob storage" job is the same -# template (`upload-build-artifacts-jobs.yml@performance`) the runtime -# perf-build calls. That template was generalized in dotnet/performance -# to take an explicit `repoName` so non-runtime tenants (us) land at -# `builds/aspnetcore/...` instead of `builds/runtime/...`, plus optional -# pool* parameters because 1ES.Official requires every job to declare -# a pool. +# Modeled on dotnet/runtime's `eng/pipelines/performance/perf-build.yml`, +# including its three-stage shape: # -# Atomicity contract (mirrors runtime's): the MissingBuildsTrigger -# Azure Function only indexes Azure DevOps builds with overall -# Status == Succeeded. Per-config Build jobs run with the default -# `continueOnError: false`; any failure sinks the overall build to -# Failed and the Function skips indexing for that SHA. Operator retries -# the whole build green. The atomic unit for indexing is the AzDO -# build, not the per-config job. (We deliberately do not write a -# separate `buildInfo.json` marker or HEAD-verify the static-website -# URL — runtime doesn't, and the AzDO build result is already the -# correct gate.) +# * RegisterBuild — runs in parallel with build; writes a tiny per-config +# buildInfo.json marker to BCS at +# builds/aspnetcore/buildArtifacts/{sha}/{configKey}/buildInfo.json. +# * build — produces per-RID Microsoft.AspNetCore.App.Runtime +# pipeline artifacts. +# * UploadArtifacts — depends on both; downloads each pipeline artifact +# and uploads it to +# builds/aspnetcore/buildArtifacts/{sha}/{configKey}/{archive}. +# +# The RegisterBuild and UploadArtifacts stages delegate to the shared +# dotnet/performance templates (`register-build-jobs.yml@performance` and +# `upload-build-artifacts-jobs.yml@performance`) — the same templates the +# runtime perf-build calls. Those templates were generalized in +# dotnet/performance to take an explicit `repoName` so non-runtime tenants +# (us) land under `builds/aspnetcore/...` instead of `builds/runtime/...`. +# +# This pipeline does NOT extend `1ES.Official.PipelineTemplate.yml`. +# Per-commit perf-build artifacts go to a private BCS cache (not redistributed +# / signed), so SDL/PoliCheck/TSA scans add no security value here. Matches +# runtime's perf-build which also runs as a plain (non-1ES) pipeline against +# the same `.NET Performance` Azure service connection. +# +# Atomicity contract: the MissingBuildsTrigger Azure Function only indexes +# Azure DevOps builds with overall Status == Succeeded. All stages run with +# the default `continueOnError: false`; any failure sinks the overall build +# to Failed and the Function skips indexing for that SHA. Operator retries +# the whole build green. The atomic unit for indexing is the AzDO build, +# not the per-config job. # # Output layout in BCS: # +# builds/aspnetcore/buildArtifacts/{sha}/{configKey}/buildInfo.json # builds/aspnetcore/buildArtifacts/{sha}/{configKey}/{archiveFile} # # Archive contents (lowercase directory names are a load-bearing contract @@ -78,10 +91,6 @@ variables: resources: repositories: - - repository: 1esPipelines - type: git - name: 1ESPipelineTemplates/1ESPipelineTemplates - ref: refs/tags/release # dotnet/performance hosts the shared BCS upload templates that runtime's # perf-build.yml also consumes. We reference its dnceng AzDO mirror # (internal/dotnet-performance) so the templates resolve at pipeline @@ -90,323 +99,132 @@ resources: type: git name: internal/dotnet-performance -extends: - template: v1/1ES.Official.PipelineTemplate.yml@1esPipelines - parameters: - settings: - networkIsolationPolicy: Permissive,CFSClean2 - sdl: - sourceAnalysisPool: - name: NetCore1ESPool-Svc-Internal - image: windows.vs2022.amd64 - os: windows - spotBugs: - enabled: false - policheck: - enabled: true - tsa: - enabled: true - - stages: - - stage: build - displayName: Build - jobs: - - # ===================================================================== - # Windows multi-arch build job - # - # Mirrors ci.yml's Windows_build pattern: build.cmd is invoked three - # times in a single agent — x64 first (with native), then x86 and - # arm64 with -noBuildNative because they cross-pack against the x64 - # native bits. Each invocation emits its own per-RID - # Microsoft.AspNetCore.App.Runtime.win-{arch}.{ver}.nupkg into the - # shared artifacts/packages/Release/Shipping/ directory. - # - # The afterBuild step then unpacks each nupkg into the lowercase - # directory layout the future aspnetcore-overlay crank PR expects. - # ===================================================================== - - template: .azure/pipelines/jobs/default-build.yml@self - parameters: - jobName: Windows_build - jobDisplayName: 'Build: Windows x64/x86/arm64 (perf-build)' - agentOs: Windows - installNodeJs: false - - steps: - - script: ./eng/build.cmd - -ci - -prepareMachine - -nativeToolsOnMachine - -arch x64 - -pack - -all - -noBuildJava - /p:OnlyPackPlatformSpecificPackages=true - $(_BuildArgs) - $(_InternalRuntimeDownloadArgs) - /bl:artifacts/log/Release/Build.x64.binlog - env: - MSBUILDUSESERVER: "1" - displayName: Build x64 - - - script: ./eng/build.cmd - -ci - -prepareMachine - -arch x86 - -pack - -all - -noBuildJava - -noBuildNative - /p:OnlyPackPlatformSpecificPackages=true - $(_BuildArgs) - $(_InternalRuntimeDownloadArgs) - -ExcludeCIBinaryLog - env: - MSBUILDUSESERVER: "1" - displayName: Build x86 - - - script: ./eng/build.cmd - -ci - -prepareMachine - -arch arm64 - -pack - -noBuildJava - -noBuildNative - /p:OnlyPackPlatformSpecificPackages=true - $(_BuildArgs) - $(_InternalRuntimeDownloadArgs) - /bl:artifacts/log/Release/Build.arm64.binlog - env: - MSBUILDUSESERVER: "1" - displayName: Build ARM64 - - afterBuild: - - powershell: | - $ErrorActionPreference = 'Stop' - - $shippingDir = Join-Path $env:BUILD_SOURCESDIRECTORY 'artifacts/packages/Release/Shipping' - $stagingRoot = Join-Path $env:BUILD_ARTIFACTSTAGINGDIRECTORY 'bcs' - - # Map: RID -> {os, arch, archive base name}. - $rids = @( - @{ rid='win-x64'; os='windows'; arch='x64'; }, - @{ rid='win-x86'; os='windows'; arch='x86'; }, - @{ rid='win-arm64'; os='windows'; arch='arm64'; } - ) - - foreach ($cfg in $rids) { - $rid = $cfg.rid - $os = $cfg.os - $arch = $cfg.arch - $configKey = "aspnetcore_${arch}_${os}" - $artifactName = "BuildArtifacts_${os}_${arch}_Release_aspnetcore" - $archiveFile = "${artifactName}.zip" - - Write-Host "" - Write-Host "=== Packing $configKey (rid=$rid) ===" - - $pattern = "Microsoft.AspNetCore.App.Runtime.$rid.*.nupkg" - $nupkg = Get-ChildItem -Path $shippingDir -Filter $pattern -File -ErrorAction SilentlyContinue | - Where-Object { $_.Name -notmatch '\.symbols\.nupkg$' } | - Sort-Object Name | - Select-Object -First 1 - if (-not $nupkg) { - throw "Could not find runtime pack nupkg matching '$pattern' under '$shippingDir'." - } - Write-Host "Found nupkg: $($nupkg.FullName)" - - # microsoft.aspnetcore.app.runtime.{rid}/Release/ <-- archive root (lowercase!) - $stageDir = Join-Path $stagingRoot $artifactName - $payloadDir = Join-Path $stageDir "microsoft.aspnetcore.app.runtime.$rid" - $releaseDir = Join-Path $payloadDir 'Release' - New-Item -ItemType Directory -Force -Path $releaseDir | Out-Null - - # Extract the nupkg (it's a zip) into Release/. - Expand-Archive -LiteralPath $nupkg.FullName -DestinationPath $releaseDir -Force - - $runtimesDir = Join-Path $releaseDir "runtimes/$rid" - if (-not (Test-Path $runtimesDir)) { - throw "Extracted runtime pack is missing expected directory '$runtimesDir'." - } - - # Sanity-check that managed assemblies landed where we expect. - $libMatches = Get-ChildItem -Path (Join-Path $runtimesDir 'lib') -Recurse -Filter 'Microsoft.AspNetCore.*.dll' -ErrorAction SilentlyContinue - if (-not $libMatches -or $libMatches.Count -eq 0) { - throw "Extracted runtime pack for $rid is missing managed Microsoft.AspNetCore.*.dll under 'runtimes/$rid/lib'." - } - Write-Host "Validated managed lib dir contains $($libMatches.Count) Microsoft.AspNetCore.*.dll(s)." - - # Defensive: strip debug-symbol files that occasionally tag along. - Get-ChildItem -Path $runtimesDir -Recurse -Include '*.pdb','*.dbg' -ErrorAction SilentlyContinue | - ForEach-Object { Remove-Item -LiteralPath $_.FullName -Force } - - # Zip the lowercase root directory (so the archive contents start - # with `microsoft.aspnetcore.app.runtime.{rid}/...`). - $archivePath = Join-Path $stageDir $archiveFile - if (Test-Path $archivePath) { Remove-Item -LiteralPath $archivePath -Force } - Compress-Archive -Path $payloadDir -DestinationPath $archivePath -CompressionLevel Optimal -Force - - # Assert the archive root matches the load-bearing contract. - Add-Type -AssemblyName System.IO.Compression.FileSystem - $zip = [System.IO.Compression.ZipFile]::OpenRead($archivePath) - try { - $expectedRoot = "microsoft.aspnetcore.app.runtime.$rid/" - $rootEntries = $zip.Entries | ForEach-Object { ($_.FullName -split '/')[0] } | Sort-Object -Unique - if ($rootEntries.Count -ne 1 -or "$($rootEntries[0])/" -ne $expectedRoot) { - throw "Archive '$archivePath' has root entries [$($rootEntries -join ', ')] but expected exactly '$expectedRoot' (load-bearing contract for future crank PR)." - } - } finally { - $zip.Dispose() - } - - # Drop the working payload directory so only the archive ends up in the published pipeline artifact. - Remove-Item -LiteralPath $payloadDir -Recurse -Force - - $size = (Get-Item $archivePath).Length - Write-Host "Produced archive: $archivePath ($size bytes)" - } - displayName: 'Pack BCS archives (win-x64 / win-x86 / win-arm64)' - - artifacts: - - name: BuildArtifacts_windows_x64_Release_aspnetcore - path: $(Build.ArtifactStagingDirectory)/bcs/BuildArtifacts_windows_x64_Release_aspnetcore - - name: BuildArtifacts_windows_x86_Release_aspnetcore - path: $(Build.ArtifactStagingDirectory)/bcs/BuildArtifacts_windows_x86_Release_aspnetcore - - name: BuildArtifacts_windows_arm64_Release_aspnetcore - path: $(Build.ArtifactStagingDirectory)/bcs/BuildArtifacts_windows_arm64_Release_aspnetcore - - name: Windows_perf_build_Logs_Attempt_$(System.JobAttempt) - path: artifacts/log/ - publishOnError: true - includeForks: true - - # ===================================================================== - # Linux x64 build job - # ===================================================================== - - template: .azure/pipelines/jobs/default-build.yml@self - parameters: - jobName: Linux_x64_build - jobDisplayName: 'Build: Linux x64 (perf-build)' - agentOs: Linux - use1ESUbuntu: true - installNodeJs: false - buildArgs: - --arch x64 - --pack - --all - --no-build-java - -p:OnlyPackPlatformSpecificPackages=true - $(_BuildArgs) - $(_InternalRuntimeDownloadArgs) - afterBuild: - - pwsh: | - $ErrorActionPreference = 'Stop' - - $shippingDir = Join-Path $env:BUILD_SOURCESDIRECTORY 'artifacts/packages/Release/Shipping' - $stagingRoot = Join-Path $env:BUILD_ARTIFACTSTAGINGDIRECTORY 'bcs' - - $rid = 'linux-x64' - $os = 'linux' - $arch = 'x64' - $configKey = "aspnetcore_${arch}_${os}" - $artifactName = "BuildArtifacts_${os}_${arch}_Release_aspnetcore" - $archiveFile = "${artifactName}.tar.gz" - - Write-Host "=== Packing $configKey (rid=$rid) ===" - - $pattern = "Microsoft.AspNetCore.App.Runtime.$rid.*.nupkg" - $nupkg = Get-ChildItem -Path $shippingDir -Filter $pattern -File -ErrorAction SilentlyContinue | - Where-Object { $_.Name -notmatch '\.symbols\.nupkg$' } | - Sort-Object Name | - Select-Object -First 1 - if (-not $nupkg) { - throw "Could not find runtime pack nupkg matching '$pattern' under '$shippingDir'." - } - Write-Host "Found nupkg: $($nupkg.FullName)" - - $stageDir = Join-Path $stagingRoot $artifactName - $payloadDir = Join-Path $stageDir "microsoft.aspnetcore.app.runtime.$rid" - $releaseDir = Join-Path $payloadDir 'Release' - New-Item -ItemType Directory -Force -Path $releaseDir | Out-Null - - # `unzip` is present on the 1ES Ubuntu agents. - & unzip -q $nupkg.FullName -d $releaseDir - if ($LASTEXITCODE -ne 0) { throw "unzip of '$($nupkg.FullName)' failed (exit $LASTEXITCODE)." } +stages: - $runtimesDir = Join-Path $releaseDir "runtimes/$rid" - if (-not (Test-Path $runtimesDir)) { - throw "Extracted runtime pack is missing expected directory '$runtimesDir'." - } - - $libMatches = Get-ChildItem -Path (Join-Path $runtimesDir 'lib') -Recurse -Filter 'Microsoft.AspNetCore.*.dll' -ErrorAction SilentlyContinue - if (-not $libMatches -or $libMatches.Count -eq 0) { - throw "Extracted runtime pack for $rid is missing managed Microsoft.AspNetCore.*.dll under 'runtimes/$rid/lib'." - } - Write-Host "Validated managed lib dir contains $($libMatches.Count) Microsoft.AspNetCore.*.dll(s)." - - Get-ChildItem -Path $runtimesDir -Recurse -Include '*.pdb','*.dbg' -ErrorAction SilentlyContinue | - ForEach-Object { Remove-Item -LiteralPath $_.FullName -Force } - - $archivePath = Join-Path $stageDir $archiveFile - if (Test-Path $archivePath) { Remove-Item -LiteralPath $archivePath -Force } - # Tar the lowercase root directory so the archive contents start with - # `microsoft.aspnetcore.app.runtime.{rid}/...`. - & tar -czf $archivePath -C $stageDir "microsoft.aspnetcore.app.runtime.$rid" - if ($LASTEXITCODE -ne 0) { throw "tar of '$payloadDir' failed (exit $LASTEXITCODE)." } - - # Assert the archive root matches the load-bearing contract. - $expectedRoot = "microsoft.aspnetcore.app.runtime.$rid/" - $firstEntry = (& tar -tzf $archivePath | Select-Object -First 1) - if (-not $firstEntry.StartsWith($expectedRoot)) { - throw "Archive '$archivePath' first entry '$firstEntry' does not start with '$expectedRoot' (load-bearing contract for future crank PR)." - } - - # Drop the working payload directory so only the archive ends up in the published pipeline artifact. - Remove-Item -LiteralPath $payloadDir -Recurse -Force - - $size = (Get-Item $archivePath).Length - Write-Host "Produced archive: $archivePath ($size bytes)" - displayName: 'Pack BCS archive (linux-x64)' - - artifacts: - - name: BuildArtifacts_linux_x64_Release_aspnetcore - path: $(Build.ArtifactStagingDirectory)/bcs/BuildArtifacts_linux_x64_Release_aspnetcore - - name: Linux_x64_perf_build_Logs_Attempt_$(System.JobAttempt) - path: artifacts/log/ - publishOnError: true - includeForks: true - - # ===================================================================== - # Linux arm64 build job (cross-build from x64 host) - # ===================================================================== - - template: .azure/pipelines/jobs/default-build.yml@self - parameters: - jobName: Linux_arm64_build - jobDisplayName: 'Build: Linux ARM64 (perf-build)' - agentOs: Linux - use1ESUbuntu: true - installNodeJs: false - buildArgs: - --arch arm64 - --pack - --all - --no-build-java - -p:OnlyPackPlatformSpecificPackages=true - $(_BuildArgs) - $(_InternalRuntimeDownloadArgs) - afterBuild: - - pwsh: | - $ErrorActionPreference = 'Stop' - - $shippingDir = Join-Path $env:BUILD_SOURCESDIRECTORY 'artifacts/packages/Release/Shipping' - $stagingRoot = Join-Path $env:BUILD_ARTIFACTSTAGINGDIRECTORY 'bcs' - - $rid = 'linux-arm64' - $os = 'linux' - $arch = 'arm64' +# ============================================================================ +# RegisterBuild stage +# +# Writes a per-configKey buildInfo.json marker to BCS at: +# $web/builds/aspnetcore/buildArtifacts/{sha}/{configKey}/buildInfo.json +# +# Runs in parallel with the build stage (dependsOn: []) — the marker only +# encodes $(Build.BuildId), it doesn't need any build output. Mirrors +# dotnet/runtime's perf-build.yml structure so dashboards / cleanup tooling +# that reads buildInfo.json work uniformly across both repos. +# +# Same gate as UploadArtifacts (internal + IndividualCI/Manual). +# ============================================================================ +- ${{ if and(ne(variables['System.TeamProject'], 'public'), or(eq(variables['Build.Reason'], 'IndividualCI'), eq(variables['Build.Reason'], 'Manual'))) }}: + - stage: RegisterBuild + displayName: 'Register Build' + dependsOn: [] + jobs: + - template: /eng/pipelines/register-build-jobs.yml@performance + parameters: + performanceRepoAlias: performance + repoName: aspnetcore + buildType: + - aspnetcore_x64_linux + - aspnetcore_arm64_linux + - aspnetcore_x64_windows + - aspnetcore_arm64_windows + - aspnetcore_x86_windows + +- stage: build + displayName: Build + jobs: + + # ===================================================================== + # Windows multi-arch build job + # + # Mirrors ci.yml's Windows_build pattern: build.cmd is invoked three + # times in a single agent — x64 first (with native), then x86 and + # arm64 with -noBuildNative because they cross-pack against the x64 + # native bits. Each invocation emits its own per-RID + # Microsoft.AspNetCore.App.Runtime.win-{arch}.{ver}.nupkg into the + # shared artifacts/packages/Release/Shipping/ directory. + # + # The afterBuild step then unpacks each nupkg into the lowercase + # directory layout the future aspnetcore-overlay crank PR expects. + # ===================================================================== + - template: .azure/pipelines/jobs/default-build.yml@self + parameters: + jobName: Windows_build + jobDisplayName: 'Build: Windows x64/x86/arm64 (perf-build)' + agentOs: Windows + installNodeJs: false + + steps: + - script: ./eng/build.cmd + -ci + -prepareMachine + -nativeToolsOnMachine + -arch x64 + -pack + -all + -noBuildJava + /p:OnlyPackPlatformSpecificPackages=true + $(_BuildArgs) + $(_InternalRuntimeDownloadArgs) + /bl:artifacts/log/Release/Build.x64.binlog + env: + MSBUILDUSESERVER: "1" + displayName: Build x64 + + - script: ./eng/build.cmd + -ci + -prepareMachine + -arch x86 + -pack + -all + -noBuildJava + -noBuildNative + /p:OnlyPackPlatformSpecificPackages=true + $(_BuildArgs) + $(_InternalRuntimeDownloadArgs) + -ExcludeCIBinaryLog + env: + MSBUILDUSESERVER: "1" + displayName: Build x86 + + - script: ./eng/build.cmd + -ci + -prepareMachine + -arch arm64 + -pack + -noBuildJava + -noBuildNative + /p:OnlyPackPlatformSpecificPackages=true + $(_BuildArgs) + $(_InternalRuntimeDownloadArgs) + /bl:artifacts/log/Release/Build.arm64.binlog + env: + MSBUILDUSESERVER: "1" + displayName: Build ARM64 + + afterBuild: + - powershell: | + $ErrorActionPreference = 'Stop' + + $shippingDir = Join-Path $env:BUILD_SOURCESDIRECTORY 'artifacts/packages/Release/Shipping' + $stagingRoot = Join-Path $env:BUILD_ARTIFACTSTAGINGDIRECTORY 'bcs' + + # Map: RID -> {os, arch, archive base name}. + $rids = @( + @{ rid='win-x64'; os='windows'; arch='x64'; }, + @{ rid='win-x86'; os='windows'; arch='x86'; }, + @{ rid='win-arm64'; os='windows'; arch='arm64'; } + ) + + foreach ($cfg in $rids) { + $rid = $cfg.rid + $os = $cfg.os + $arch = $cfg.arch $configKey = "aspnetcore_${arch}_${os}" $artifactName = "BuildArtifacts_${os}_${arch}_Release_aspnetcore" - $archiveFile = "${artifactName}.tar.gz" + $archiveFile = "${artifactName}.zip" + Write-Host "" Write-Host "=== Packing $configKey (rid=$rid) ===" $pattern = "Microsoft.AspNetCore.App.Runtime.$rid.*.nupkg" @@ -419,38 +237,48 @@ extends: } Write-Host "Found nupkg: $($nupkg.FullName)" - $stageDir = Join-Path $stagingRoot $artifactName + # microsoft.aspnetcore.app.runtime.{rid}/Release/ <-- archive root (lowercase!) + $stageDir = Join-Path $stagingRoot $artifactName $payloadDir = Join-Path $stageDir "microsoft.aspnetcore.app.runtime.$rid" $releaseDir = Join-Path $payloadDir 'Release' New-Item -ItemType Directory -Force -Path $releaseDir | Out-Null - & unzip -q $nupkg.FullName -d $releaseDir - if ($LASTEXITCODE -ne 0) { throw "unzip of '$($nupkg.FullName)' failed (exit $LASTEXITCODE)." } + # Extract the nupkg (it's a zip) into Release/. + Expand-Archive -LiteralPath $nupkg.FullName -DestinationPath $releaseDir -Force $runtimesDir = Join-Path $releaseDir "runtimes/$rid" if (-not (Test-Path $runtimesDir)) { throw "Extracted runtime pack is missing expected directory '$runtimesDir'." } + # Sanity-check that managed assemblies landed where we expect. $libMatches = Get-ChildItem -Path (Join-Path $runtimesDir 'lib') -Recurse -Filter 'Microsoft.AspNetCore.*.dll' -ErrorAction SilentlyContinue if (-not $libMatches -or $libMatches.Count -eq 0) { throw "Extracted runtime pack for $rid is missing managed Microsoft.AspNetCore.*.dll under 'runtimes/$rid/lib'." } Write-Host "Validated managed lib dir contains $($libMatches.Count) Microsoft.AspNetCore.*.dll(s)." + # Defensive: strip debug-symbol files that occasionally tag along. Get-ChildItem -Path $runtimesDir -Recurse -Include '*.pdb','*.dbg' -ErrorAction SilentlyContinue | ForEach-Object { Remove-Item -LiteralPath $_.FullName -Force } + # Zip the lowercase root directory (so the archive contents start + # with `microsoft.aspnetcore.app.runtime.{rid}/...`). $archivePath = Join-Path $stageDir $archiveFile if (Test-Path $archivePath) { Remove-Item -LiteralPath $archivePath -Force } - & tar -czf $archivePath -C $stageDir "microsoft.aspnetcore.app.runtime.$rid" - if ($LASTEXITCODE -ne 0) { throw "tar of '$payloadDir' failed (exit $LASTEXITCODE)." } + Compress-Archive -Path $payloadDir -DestinationPath $archivePath -CompressionLevel Optimal -Force # Assert the archive root matches the load-bearing contract. - $expectedRoot = "microsoft.aspnetcore.app.runtime.$rid/" - $firstEntry = (& tar -tzf $archivePath | Select-Object -First 1) - if (-not $firstEntry.StartsWith($expectedRoot)) { - throw "Archive '$archivePath' first entry '$firstEntry' does not start with '$expectedRoot' (load-bearing contract for future crank PR)." + Add-Type -AssemblyName System.IO.Compression.FileSystem + $zip = [System.IO.Compression.ZipFile]::OpenRead($archivePath) + try { + $expectedRoot = "microsoft.aspnetcore.app.runtime.$rid/" + $rootEntries = $zip.Entries | ForEach-Object { ($_.FullName -split '/')[0] } | Sort-Object -Unique + if ($rootEntries.Count -ne 1 -or "$($rootEntries[0])/" -ne $expectedRoot) { + throw "Archive '$archivePath' has root entries [$($rootEntries -join ', ')] but expected exactly '$expectedRoot' (load-bearing contract for future crank PR)." + } + } finally { + $zip.Dispose() } # Drop the working payload directory so only the archive ends up in the published pipeline artifact. @@ -458,58 +286,248 @@ extends: $size = (Get-Item $archivePath).Length Write-Host "Produced archive: $archivePath ($size bytes)" - displayName: 'Pack BCS archive (linux-arm64)' - - artifacts: - - name: BuildArtifacts_linux_arm64_Release_aspnetcore - path: $(Build.ArtifactStagingDirectory)/bcs/BuildArtifacts_linux_arm64_Release_aspnetcore - - name: Linux_arm64_perf_build_Logs_Attempt_$(System.JobAttempt) - path: artifacts/log/ - publishOnError: true - includeForks: true - - # ========================================================================== - # UploadArtifacts stage - # - # Delegates to dotnet/performance's `upload-build-artifacts-jobs.yml` - # dispatcher (the same template runtime's perf-build.yml calls), which - # fans out into per-configKey `templates/upload-build-artifacts-job.yml` - # invocations that each: - # - # 1. Download the matching pipeline artifact from the Build stage. - # 2. `az storage blob upload` it to: - # $web/builds/aspnetcore/buildArtifacts/{sha}/{configKey}/{file} - # - # Gated to internal + (IndividualCI | Manual). IndividualCI covers the - # steady-state per-commit flow; Manual covers the runbook's "queue one - # seed run" step (so a manually queued run actually uploads, instead of - # silently producing a successful AzDO build with zero BCS payloads). - # - # `condition: succeeded()` then means any Build-stage job failure skips - # this entire stage, which makes the overall AzDO build report as - # Failed and the MissingBuildsTrigger Function skips indexing for this - # SHA (whole-pipeline-indexing contract). - # ========================================================================== - - ${{ if and(ne(variables['System.TeamProject'], 'public'), or(eq(variables['Build.Reason'], 'IndividualCI'), eq(variables['Build.Reason'], 'Manual'))) }}: - - stage: UploadArtifacts - displayName: 'Upload Artifacts to BCS' - dependsOn: build - condition: succeeded() - jobs: - - template: /eng/pipelines/upload-build-artifacts-jobs.yml@performance - parameters: - performanceRepoAlias: performance - repoName: aspnetcore - # 1ES.Official requires every job to declare a pool, so we pass - # the dnceng internal 1ES Ubuntu pool through to each per-artifact - # upload job. (`az storage blob upload` is platform-agnostic so - # a Linux pool works for the Windows .zip archives too.) - poolName: $(DncEngInternalBuildPool) - poolImage: 1es-ubuntu-2204 - poolOs: linux - buildType: - - aspnetcore_x64_linux - - aspnetcore_arm64_linux - - aspnetcore_x64_windows - - aspnetcore_arm64_windows - - aspnetcore_x86_windows + } + displayName: 'Pack BCS archives (win-x64 / win-x86 / win-arm64)' + + artifacts: + - name: BuildArtifacts_windows_x64_Release_aspnetcore + path: $(Build.ArtifactStagingDirectory)/bcs/BuildArtifacts_windows_x64_Release_aspnetcore + - name: BuildArtifacts_windows_x86_Release_aspnetcore + path: $(Build.ArtifactStagingDirectory)/bcs/BuildArtifacts_windows_x86_Release_aspnetcore + - name: BuildArtifacts_windows_arm64_Release_aspnetcore + path: $(Build.ArtifactStagingDirectory)/bcs/BuildArtifacts_windows_arm64_Release_aspnetcore + - name: Windows_perf_build_Logs_Attempt_$(System.JobAttempt) + path: artifacts/log/ + publishOnError: true + includeForks: true + + # ===================================================================== + # Linux x64 build job + # ===================================================================== + - template: .azure/pipelines/jobs/default-build.yml@self + parameters: + jobName: Linux_x64_build + jobDisplayName: 'Build: Linux x64 (perf-build)' + agentOs: Linux + use1ESUbuntu: true + installNodeJs: false + buildArgs: + --arch x64 + --pack + --all + --no-build-java + -p:OnlyPackPlatformSpecificPackages=true + $(_BuildArgs) + $(_InternalRuntimeDownloadArgs) + afterBuild: + - pwsh: | + $ErrorActionPreference = 'Stop' + + $shippingDir = Join-Path $env:BUILD_SOURCESDIRECTORY 'artifacts/packages/Release/Shipping' + $stagingRoot = Join-Path $env:BUILD_ARTIFACTSTAGINGDIRECTORY 'bcs' + + $rid = 'linux-x64' + $os = 'linux' + $arch = 'x64' + $configKey = "aspnetcore_${arch}_${os}" + $artifactName = "BuildArtifacts_${os}_${arch}_Release_aspnetcore" + $archiveFile = "${artifactName}.tar.gz" + + Write-Host "=== Packing $configKey (rid=$rid) ===" + + $pattern = "Microsoft.AspNetCore.App.Runtime.$rid.*.nupkg" + $nupkg = Get-ChildItem -Path $shippingDir -Filter $pattern -File -ErrorAction SilentlyContinue | + Where-Object { $_.Name -notmatch '\.symbols\.nupkg$' } | + Sort-Object Name | + Select-Object -First 1 + if (-not $nupkg) { + throw "Could not find runtime pack nupkg matching '$pattern' under '$shippingDir'." + } + Write-Host "Found nupkg: $($nupkg.FullName)" + + $stageDir = Join-Path $stagingRoot $artifactName + $payloadDir = Join-Path $stageDir "microsoft.aspnetcore.app.runtime.$rid" + $releaseDir = Join-Path $payloadDir 'Release' + New-Item -ItemType Directory -Force -Path $releaseDir | Out-Null + + # `unzip` is present on the 1ES Ubuntu agents. + & unzip -q $nupkg.FullName -d $releaseDir + if ($LASTEXITCODE -ne 0) { throw "unzip of '$($nupkg.FullName)' failed (exit $LASTEXITCODE)." } + + $runtimesDir = Join-Path $releaseDir "runtimes/$rid" + if (-not (Test-Path $runtimesDir)) { + throw "Extracted runtime pack is missing expected directory '$runtimesDir'." + } + + $libMatches = Get-ChildItem -Path (Join-Path $runtimesDir 'lib') -Recurse -Filter 'Microsoft.AspNetCore.*.dll' -ErrorAction SilentlyContinue + if (-not $libMatches -or $libMatches.Count -eq 0) { + throw "Extracted runtime pack for $rid is missing managed Microsoft.AspNetCore.*.dll under 'runtimes/$rid/lib'." + } + Write-Host "Validated managed lib dir contains $($libMatches.Count) Microsoft.AspNetCore.*.dll(s)." + + Get-ChildItem -Path $runtimesDir -Recurse -Include '*.pdb','*.dbg' -ErrorAction SilentlyContinue | + ForEach-Object { Remove-Item -LiteralPath $_.FullName -Force } + + $archivePath = Join-Path $stageDir $archiveFile + if (Test-Path $archivePath) { Remove-Item -LiteralPath $archivePath -Force } + # Tar the lowercase root directory so the archive contents start with + # `microsoft.aspnetcore.app.runtime.{rid}/...`. + & tar -czf $archivePath -C $stageDir "microsoft.aspnetcore.app.runtime.$rid" + if ($LASTEXITCODE -ne 0) { throw "tar of '$payloadDir' failed (exit $LASTEXITCODE)." } + + # Assert the archive root matches the load-bearing contract. + $expectedRoot = "microsoft.aspnetcore.app.runtime.$rid/" + $firstEntry = (& tar -tzf $archivePath | Select-Object -First 1) + if (-not $firstEntry.StartsWith($expectedRoot)) { + throw "Archive '$archivePath' first entry '$firstEntry' does not start with '$expectedRoot' (load-bearing contract for future crank PR)." + } + + # Drop the working payload directory so only the archive ends up in the published pipeline artifact. + Remove-Item -LiteralPath $payloadDir -Recurse -Force + + $size = (Get-Item $archivePath).Length + Write-Host "Produced archive: $archivePath ($size bytes)" + displayName: 'Pack BCS archive (linux-x64)' + + artifacts: + - name: BuildArtifacts_linux_x64_Release_aspnetcore + path: $(Build.ArtifactStagingDirectory)/bcs/BuildArtifacts_linux_x64_Release_aspnetcore + - name: Linux_x64_perf_build_Logs_Attempt_$(System.JobAttempt) + path: artifacts/log/ + publishOnError: true + includeForks: true + + # ===================================================================== + # Linux arm64 build job (cross-build from x64 host) + # ===================================================================== + - template: .azure/pipelines/jobs/default-build.yml@self + parameters: + jobName: Linux_arm64_build + jobDisplayName: 'Build: Linux ARM64 (perf-build)' + agentOs: Linux + use1ESUbuntu: true + installNodeJs: false + buildArgs: + --arch arm64 + --pack + --all + --no-build-java + -p:OnlyPackPlatformSpecificPackages=true + $(_BuildArgs) + $(_InternalRuntimeDownloadArgs) + afterBuild: + - pwsh: | + $ErrorActionPreference = 'Stop' + + $shippingDir = Join-Path $env:BUILD_SOURCESDIRECTORY 'artifacts/packages/Release/Shipping' + $stagingRoot = Join-Path $env:BUILD_ARTIFACTSTAGINGDIRECTORY 'bcs' + + $rid = 'linux-arm64' + $os = 'linux' + $arch = 'arm64' + $configKey = "aspnetcore_${arch}_${os}" + $artifactName = "BuildArtifacts_${os}_${arch}_Release_aspnetcore" + $archiveFile = "${artifactName}.tar.gz" + + Write-Host "=== Packing $configKey (rid=$rid) ===" + + $pattern = "Microsoft.AspNetCore.App.Runtime.$rid.*.nupkg" + $nupkg = Get-ChildItem -Path $shippingDir -Filter $pattern -File -ErrorAction SilentlyContinue | + Where-Object { $_.Name -notmatch '\.symbols\.nupkg$' } | + Sort-Object Name | + Select-Object -First 1 + if (-not $nupkg) { + throw "Could not find runtime pack nupkg matching '$pattern' under '$shippingDir'." + } + Write-Host "Found nupkg: $($nupkg.FullName)" + + $stageDir = Join-Path $stagingRoot $artifactName + $payloadDir = Join-Path $stageDir "microsoft.aspnetcore.app.runtime.$rid" + $releaseDir = Join-Path $payloadDir 'Release' + New-Item -ItemType Directory -Force -Path $releaseDir | Out-Null + + & unzip -q $nupkg.FullName -d $releaseDir + if ($LASTEXITCODE -ne 0) { throw "unzip of '$($nupkg.FullName)' failed (exit $LASTEXITCODE)." } + + $runtimesDir = Join-Path $releaseDir "runtimes/$rid" + if (-not (Test-Path $runtimesDir)) { + throw "Extracted runtime pack is missing expected directory '$runtimesDir'." + } + + $libMatches = Get-ChildItem -Path (Join-Path $runtimesDir 'lib') -Recurse -Filter 'Microsoft.AspNetCore.*.dll' -ErrorAction SilentlyContinue + if (-not $libMatches -or $libMatches.Count -eq 0) { + throw "Extracted runtime pack for $rid is missing managed Microsoft.AspNetCore.*.dll under 'runtimes/$rid/lib'." + } + Write-Host "Validated managed lib dir contains $($libMatches.Count) Microsoft.AspNetCore.*.dll(s)." + + Get-ChildItem -Path $runtimesDir -Recurse -Include '*.pdb','*.dbg' -ErrorAction SilentlyContinue | + ForEach-Object { Remove-Item -LiteralPath $_.FullName -Force } + + $archivePath = Join-Path $stageDir $archiveFile + if (Test-Path $archivePath) { Remove-Item -LiteralPath $archivePath -Force } + & tar -czf $archivePath -C $stageDir "microsoft.aspnetcore.app.runtime.$rid" + if ($LASTEXITCODE -ne 0) { throw "tar of '$payloadDir' failed (exit $LASTEXITCODE)." } + + # Assert the archive root matches the load-bearing contract. + $expectedRoot = "microsoft.aspnetcore.app.runtime.$rid/" + $firstEntry = (& tar -tzf $archivePath | Select-Object -First 1) + if (-not $firstEntry.StartsWith($expectedRoot)) { + throw "Archive '$archivePath' first entry '$firstEntry' does not start with '$expectedRoot' (load-bearing contract for future crank PR)." + } + + # Drop the working payload directory so only the archive ends up in the published pipeline artifact. + Remove-Item -LiteralPath $payloadDir -Recurse -Force + + $size = (Get-Item $archivePath).Length + Write-Host "Produced archive: $archivePath ($size bytes)" + displayName: 'Pack BCS archive (linux-arm64)' + + artifacts: + - name: BuildArtifacts_linux_arm64_Release_aspnetcore + path: $(Build.ArtifactStagingDirectory)/bcs/BuildArtifacts_linux_arm64_Release_aspnetcore + - name: Linux_arm64_perf_build_Logs_Attempt_$(System.JobAttempt) + path: artifacts/log/ + publishOnError: true + includeForks: true + +# ========================================================================== +# ============================================================================ +# UploadArtifacts stage +# +# Delegates to dotnet/performance's `upload-build-artifacts-jobs.yml` +# dispatcher (the same template runtime's perf-build.yml calls), which +# fans out into per-configKey `templates/upload-build-artifacts-job.yml` +# invocations that each: +# +# 1. Download the matching pipeline artifact from the Build stage. +# 2. `az storage blob upload` it to: +# $web/builds/aspnetcore/buildArtifacts/{sha}/{configKey}/{file} +# +# Depends on both build and RegisterBuild so the upload only proceeds once +# both the archives and the buildInfo.json markers are in flight. `condition: +# succeeded()` means any build / RegisterBuild failure sinks the overall +# AzDO build to Failed, and the MissingBuildsTrigger Function skips +# indexing for this SHA (whole-pipeline-indexing contract). +# +# Gated to internal + (IndividualCI | Manual). IndividualCI covers the +# steady-state per-commit flow; Manual covers the runbook's "queue one +# seed run" step. +# ============================================================================ +- ${{ if and(ne(variables['System.TeamProject'], 'public'), or(eq(variables['Build.Reason'], 'IndividualCI'), eq(variables['Build.Reason'], 'Manual'))) }}: + - stage: UploadArtifacts + displayName: 'Upload Artifacts to BCS' + dependsOn: + - build + - RegisterBuild + condition: succeeded() + jobs: + - template: /eng/pipelines/upload-build-artifacts-jobs.yml@performance + parameters: + performanceRepoAlias: performance + repoName: aspnetcore + buildType: + - aspnetcore_x64_linux + - aspnetcore_arm64_linux + - aspnetcore_x64_windows + - aspnetcore_arm64_windows + - aspnetcore_x86_windows From d3779cc0e8fce49e64c93952a2b82526e4656ee4 Mon Sep 17 00:00:00 2001 From: Parker Bibus Date: Thu, 11 Jun 2026 16:20:31 -0700 Subject: [PATCH 4/5] Capitalize 'Build' stage name to match leaf template's stageDependencies reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shared dotnet/performance leaf template upload-build-artifacts-job.yml hardcodes: condition: eq(stageDependencies.Build..result, 'Succeeded') In Azure Pipelines expression evaluation, stageDependencies. is case-sensitive (verified via Microsoft Learn / Azure DevOps docs). The previous lowercase 'stage: build' here would silently resolve to a null result, every per-artifact upload job's condition would evaluate false, and NO artifacts would ever land in BCS — but the pipeline would report as Succeeded since no job actually failed. A true silent failure. Capitalizing to 'Build' matches dotnet/runtime's perf-build.yml convention and the leaf template's hardcoded reference. dependsOn list for the UploadArtifacts stage updated to match. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .azure/pipelines/perf-build.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.azure/pipelines/perf-build.yml b/.azure/pipelines/perf-build.yml index bb729ecd8d19..e41b91908c8c 100644 --- a/.azure/pipelines/perf-build.yml +++ b/.azure/pipelines/perf-build.yml @@ -130,7 +130,7 @@ stages: - aspnetcore_arm64_windows - aspnetcore_x86_windows -- stage: build +- stage: Build displayName: Build jobs: @@ -517,7 +517,7 @@ stages: - stage: UploadArtifacts displayName: 'Upload Artifacts to BCS' dependsOn: - - build + - Build - RegisterBuild condition: succeeded() jobs: From 3c52640223acb5d601c4391cd4f1862a329f62ea Mon Sep 17 00:00:00 2001 From: Parker Bibus Date: Tue, 16 Jun 2026 15:09:05 -0700 Subject: [PATCH 5/5] Extract BCS pack recipe into one shared pwsh script The three build jobs (Windows x64/x86/arm64, Linux x64, Linux arm64) each carried a near-verbatim copy of the "find the runtime-pack nupkg, extract it into the lowercase microsoft.aspnetcore.app.runtime.{rid}/Release/ layout, and zip it" recipe in their afterBuild hook. Replace all three with a single, parameterized .azure/pipelines/tools/pack-bcs-archives.ps1 (-Rids / -Format) so there is exactly one copy of the find-and-zip logic to maintain. Verified the script's nupkg assumptions against real published packages (Microsoft.AspNetCore.App.Runtime.{linux-x64,win-x64} 8.0.28): the runtime pack name, the runtimes/{rid}/lib managed-assembly layout, the win native dir, and the absence of pdb/dbg all match what the recipe expects. Invoke via pwsh (PowerShell 7), not Windows PowerShell 5.1: 5.1's Compress-Archive writes backslash path separators into the zip, which a .NET ZipFile extraction on a Linux crank agent treats as one flat filename, so crank's FindDirectory("microsoft.aspnetcore.app.runtime.{rid}") would never match. The prior inline Windows block used powershell: (5.1) and would have produced corrupt Windows archives. The archive-root assertion now also runs under pwsh and fails the build loudly if the contract is ever broken. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .azure/pipelines/perf-build.yml | 226 +------------------ .azure/pipelines/tools/pack-bcs-archives.ps1 | 187 +++++++++++++++ 2 files changed, 198 insertions(+), 215 deletions(-) create mode 100644 .azure/pipelines/tools/pack-bcs-archives.ps1 diff --git a/.azure/pipelines/perf-build.yml b/.azure/pipelines/perf-build.yml index e41b91908c8c..eefd5c44bff7 100644 --- a/.azure/pipelines/perf-build.yml +++ b/.azure/pipelines/perf-build.yml @@ -144,8 +144,13 @@ stages: # Microsoft.AspNetCore.App.Runtime.win-{arch}.{ver}.nupkg into the # shared artifacts/packages/Release/Shipping/ directory. # - # The afterBuild step then unpacks each nupkg into the lowercase - # directory layout the future aspnetcore-overlay crank PR expects. + # The afterBuild step delegates to the shared + # .azure/pipelines/tools/pack-bcs-archives.ps1 script (the single, + # canonical find-and-zip recipe used by every build job here), which + # unpacks each nupkg into the lowercase directory layout the future + # aspnetcore-overlay crank PR expects. It is invoked via `pwsh` (not + # Windows PowerShell 5.1) because 5.1's Compress-Archive writes + # backslash path separators, which would corrupt the archive for crank. # ===================================================================== - template: .azure/pipelines/jobs/default-build.yml@self parameters: @@ -203,90 +208,8 @@ stages: displayName: Build ARM64 afterBuild: - - powershell: | - $ErrorActionPreference = 'Stop' - - $shippingDir = Join-Path $env:BUILD_SOURCESDIRECTORY 'artifacts/packages/Release/Shipping' - $stagingRoot = Join-Path $env:BUILD_ARTIFACTSTAGINGDIRECTORY 'bcs' - - # Map: RID -> {os, arch, archive base name}. - $rids = @( - @{ rid='win-x64'; os='windows'; arch='x64'; }, - @{ rid='win-x86'; os='windows'; arch='x86'; }, - @{ rid='win-arm64'; os='windows'; arch='arm64'; } - ) - - foreach ($cfg in $rids) { - $rid = $cfg.rid - $os = $cfg.os - $arch = $cfg.arch - $configKey = "aspnetcore_${arch}_${os}" - $artifactName = "BuildArtifacts_${os}_${arch}_Release_aspnetcore" - $archiveFile = "${artifactName}.zip" - - Write-Host "" - Write-Host "=== Packing $configKey (rid=$rid) ===" - - $pattern = "Microsoft.AspNetCore.App.Runtime.$rid.*.nupkg" - $nupkg = Get-ChildItem -Path $shippingDir -Filter $pattern -File -ErrorAction SilentlyContinue | - Where-Object { $_.Name -notmatch '\.symbols\.nupkg$' } | - Sort-Object Name | - Select-Object -First 1 - if (-not $nupkg) { - throw "Could not find runtime pack nupkg matching '$pattern' under '$shippingDir'." - } - Write-Host "Found nupkg: $($nupkg.FullName)" - - # microsoft.aspnetcore.app.runtime.{rid}/Release/ <-- archive root (lowercase!) - $stageDir = Join-Path $stagingRoot $artifactName - $payloadDir = Join-Path $stageDir "microsoft.aspnetcore.app.runtime.$rid" - $releaseDir = Join-Path $payloadDir 'Release' - New-Item -ItemType Directory -Force -Path $releaseDir | Out-Null - - # Extract the nupkg (it's a zip) into Release/. - Expand-Archive -LiteralPath $nupkg.FullName -DestinationPath $releaseDir -Force - - $runtimesDir = Join-Path $releaseDir "runtimes/$rid" - if (-not (Test-Path $runtimesDir)) { - throw "Extracted runtime pack is missing expected directory '$runtimesDir'." - } - - # Sanity-check that managed assemblies landed where we expect. - $libMatches = Get-ChildItem -Path (Join-Path $runtimesDir 'lib') -Recurse -Filter 'Microsoft.AspNetCore.*.dll' -ErrorAction SilentlyContinue - if (-not $libMatches -or $libMatches.Count -eq 0) { - throw "Extracted runtime pack for $rid is missing managed Microsoft.AspNetCore.*.dll under 'runtimes/$rid/lib'." - } - Write-Host "Validated managed lib dir contains $($libMatches.Count) Microsoft.AspNetCore.*.dll(s)." - - # Defensive: strip debug-symbol files that occasionally tag along. - Get-ChildItem -Path $runtimesDir -Recurse -Include '*.pdb','*.dbg' -ErrorAction SilentlyContinue | - ForEach-Object { Remove-Item -LiteralPath $_.FullName -Force } - - # Zip the lowercase root directory (so the archive contents start - # with `microsoft.aspnetcore.app.runtime.{rid}/...`). - $archivePath = Join-Path $stageDir $archiveFile - if (Test-Path $archivePath) { Remove-Item -LiteralPath $archivePath -Force } - Compress-Archive -Path $payloadDir -DestinationPath $archivePath -CompressionLevel Optimal -Force - - # Assert the archive root matches the load-bearing contract. - Add-Type -AssemblyName System.IO.Compression.FileSystem - $zip = [System.IO.Compression.ZipFile]::OpenRead($archivePath) - try { - $expectedRoot = "microsoft.aspnetcore.app.runtime.$rid/" - $rootEntries = $zip.Entries | ForEach-Object { ($_.FullName -split '/')[0] } | Sort-Object -Unique - if ($rootEntries.Count -ne 1 -or "$($rootEntries[0])/" -ne $expectedRoot) { - throw "Archive '$archivePath' has root entries [$($rootEntries -join ', ')] but expected exactly '$expectedRoot' (load-bearing contract for future crank PR)." - } - } finally { - $zip.Dispose() - } - - # Drop the working payload directory so only the archive ends up in the published pipeline artifact. - Remove-Item -LiteralPath $payloadDir -Recurse -Force - - $size = (Get-Item $archivePath).Length - Write-Host "Produced archive: $archivePath ($size bytes)" - } + - pwsh: | + & "$(Build.SourcesDirectory)/.azure/pipelines/tools/pack-bcs-archives.ps1" -Rids win-x64,win-x86,win-arm64 -Format zip displayName: 'Pack BCS archives (win-x64 / win-x86 / win-arm64)' artifacts: @@ -321,72 +244,7 @@ stages: $(_InternalRuntimeDownloadArgs) afterBuild: - pwsh: | - $ErrorActionPreference = 'Stop' - - $shippingDir = Join-Path $env:BUILD_SOURCESDIRECTORY 'artifacts/packages/Release/Shipping' - $stagingRoot = Join-Path $env:BUILD_ARTIFACTSTAGINGDIRECTORY 'bcs' - - $rid = 'linux-x64' - $os = 'linux' - $arch = 'x64' - $configKey = "aspnetcore_${arch}_${os}" - $artifactName = "BuildArtifacts_${os}_${arch}_Release_aspnetcore" - $archiveFile = "${artifactName}.tar.gz" - - Write-Host "=== Packing $configKey (rid=$rid) ===" - - $pattern = "Microsoft.AspNetCore.App.Runtime.$rid.*.nupkg" - $nupkg = Get-ChildItem -Path $shippingDir -Filter $pattern -File -ErrorAction SilentlyContinue | - Where-Object { $_.Name -notmatch '\.symbols\.nupkg$' } | - Sort-Object Name | - Select-Object -First 1 - if (-not $nupkg) { - throw "Could not find runtime pack nupkg matching '$pattern' under '$shippingDir'." - } - Write-Host "Found nupkg: $($nupkg.FullName)" - - $stageDir = Join-Path $stagingRoot $artifactName - $payloadDir = Join-Path $stageDir "microsoft.aspnetcore.app.runtime.$rid" - $releaseDir = Join-Path $payloadDir 'Release' - New-Item -ItemType Directory -Force -Path $releaseDir | Out-Null - - # `unzip` is present on the 1ES Ubuntu agents. - & unzip -q $nupkg.FullName -d $releaseDir - if ($LASTEXITCODE -ne 0) { throw "unzip of '$($nupkg.FullName)' failed (exit $LASTEXITCODE)." } - - $runtimesDir = Join-Path $releaseDir "runtimes/$rid" - if (-not (Test-Path $runtimesDir)) { - throw "Extracted runtime pack is missing expected directory '$runtimesDir'." - } - - $libMatches = Get-ChildItem -Path (Join-Path $runtimesDir 'lib') -Recurse -Filter 'Microsoft.AspNetCore.*.dll' -ErrorAction SilentlyContinue - if (-not $libMatches -or $libMatches.Count -eq 0) { - throw "Extracted runtime pack for $rid is missing managed Microsoft.AspNetCore.*.dll under 'runtimes/$rid/lib'." - } - Write-Host "Validated managed lib dir contains $($libMatches.Count) Microsoft.AspNetCore.*.dll(s)." - - Get-ChildItem -Path $runtimesDir -Recurse -Include '*.pdb','*.dbg' -ErrorAction SilentlyContinue | - ForEach-Object { Remove-Item -LiteralPath $_.FullName -Force } - - $archivePath = Join-Path $stageDir $archiveFile - if (Test-Path $archivePath) { Remove-Item -LiteralPath $archivePath -Force } - # Tar the lowercase root directory so the archive contents start with - # `microsoft.aspnetcore.app.runtime.{rid}/...`. - & tar -czf $archivePath -C $stageDir "microsoft.aspnetcore.app.runtime.$rid" - if ($LASTEXITCODE -ne 0) { throw "tar of '$payloadDir' failed (exit $LASTEXITCODE)." } - - # Assert the archive root matches the load-bearing contract. - $expectedRoot = "microsoft.aspnetcore.app.runtime.$rid/" - $firstEntry = (& tar -tzf $archivePath | Select-Object -First 1) - if (-not $firstEntry.StartsWith($expectedRoot)) { - throw "Archive '$archivePath' first entry '$firstEntry' does not start with '$expectedRoot' (load-bearing contract for future crank PR)." - } - - # Drop the working payload directory so only the archive ends up in the published pipeline artifact. - Remove-Item -LiteralPath $payloadDir -Recurse -Force - - $size = (Get-Item $archivePath).Length - Write-Host "Produced archive: $archivePath ($size bytes)" + & "$(Build.SourcesDirectory)/.azure/pipelines/tools/pack-bcs-archives.ps1" -Rids linux-x64 -Format targz displayName: 'Pack BCS archive (linux-x64)' artifacts: @@ -417,69 +275,7 @@ stages: $(_InternalRuntimeDownloadArgs) afterBuild: - pwsh: | - $ErrorActionPreference = 'Stop' - - $shippingDir = Join-Path $env:BUILD_SOURCESDIRECTORY 'artifacts/packages/Release/Shipping' - $stagingRoot = Join-Path $env:BUILD_ARTIFACTSTAGINGDIRECTORY 'bcs' - - $rid = 'linux-arm64' - $os = 'linux' - $arch = 'arm64' - $configKey = "aspnetcore_${arch}_${os}" - $artifactName = "BuildArtifacts_${os}_${arch}_Release_aspnetcore" - $archiveFile = "${artifactName}.tar.gz" - - Write-Host "=== Packing $configKey (rid=$rid) ===" - - $pattern = "Microsoft.AspNetCore.App.Runtime.$rid.*.nupkg" - $nupkg = Get-ChildItem -Path $shippingDir -Filter $pattern -File -ErrorAction SilentlyContinue | - Where-Object { $_.Name -notmatch '\.symbols\.nupkg$' } | - Sort-Object Name | - Select-Object -First 1 - if (-not $nupkg) { - throw "Could not find runtime pack nupkg matching '$pattern' under '$shippingDir'." - } - Write-Host "Found nupkg: $($nupkg.FullName)" - - $stageDir = Join-Path $stagingRoot $artifactName - $payloadDir = Join-Path $stageDir "microsoft.aspnetcore.app.runtime.$rid" - $releaseDir = Join-Path $payloadDir 'Release' - New-Item -ItemType Directory -Force -Path $releaseDir | Out-Null - - & unzip -q $nupkg.FullName -d $releaseDir - if ($LASTEXITCODE -ne 0) { throw "unzip of '$($nupkg.FullName)' failed (exit $LASTEXITCODE)." } - - $runtimesDir = Join-Path $releaseDir "runtimes/$rid" - if (-not (Test-Path $runtimesDir)) { - throw "Extracted runtime pack is missing expected directory '$runtimesDir'." - } - - $libMatches = Get-ChildItem -Path (Join-Path $runtimesDir 'lib') -Recurse -Filter 'Microsoft.AspNetCore.*.dll' -ErrorAction SilentlyContinue - if (-not $libMatches -or $libMatches.Count -eq 0) { - throw "Extracted runtime pack for $rid is missing managed Microsoft.AspNetCore.*.dll under 'runtimes/$rid/lib'." - } - Write-Host "Validated managed lib dir contains $($libMatches.Count) Microsoft.AspNetCore.*.dll(s)." - - Get-ChildItem -Path $runtimesDir -Recurse -Include '*.pdb','*.dbg' -ErrorAction SilentlyContinue | - ForEach-Object { Remove-Item -LiteralPath $_.FullName -Force } - - $archivePath = Join-Path $stageDir $archiveFile - if (Test-Path $archivePath) { Remove-Item -LiteralPath $archivePath -Force } - & tar -czf $archivePath -C $stageDir "microsoft.aspnetcore.app.runtime.$rid" - if ($LASTEXITCODE -ne 0) { throw "tar of '$payloadDir' failed (exit $LASTEXITCODE)." } - - # Assert the archive root matches the load-bearing contract. - $expectedRoot = "microsoft.aspnetcore.app.runtime.$rid/" - $firstEntry = (& tar -tzf $archivePath | Select-Object -First 1) - if (-not $firstEntry.StartsWith($expectedRoot)) { - throw "Archive '$archivePath' first entry '$firstEntry' does not start with '$expectedRoot' (load-bearing contract for future crank PR)." - } - - # Drop the working payload directory so only the archive ends up in the published pipeline artifact. - Remove-Item -LiteralPath $payloadDir -Recurse -Force - - $size = (Get-Item $archivePath).Length - Write-Host "Produced archive: $archivePath ($size bytes)" + & "$(Build.SourcesDirectory)/.azure/pipelines/tools/pack-bcs-archives.ps1" -Rids linux-arm64 -Format targz displayName: 'Pack BCS archive (linux-arm64)' artifacts: diff --git a/.azure/pipelines/tools/pack-bcs-archives.ps1 b/.azure/pipelines/tools/pack-bcs-archives.ps1 new file mode 100644 index 000000000000..6a943bf96fcd --- /dev/null +++ b/.azure/pipelines/tools/pack-bcs-archives.ps1 @@ -0,0 +1,187 @@ +<# +.SYNOPSIS + Packs per-RID Microsoft.AspNetCore.App runtime packs into Build Cache + Service (BCS) archives for the perf-build pipeline. + +.DESCRIPTION + This is the single, canonical "find the runtime-pack nupkg and zip it into + the BCS archive layout" recipe used by every job in perf-build.yml (Windows + x64/x86/arm64, Linux x64, Linux arm64). The build jobs invoke it from their + `afterBuild` hook with the RID(s) they produced and the archive format that + matches their platform convention (.zip on Windows, .tar.gz on Linux). + + For each RID it: + 1. Locates artifacts/packages/Release/Shipping/Microsoft.AspNetCore.App.Runtime.{rid}.*.nupkg + (excluding the *.symbols.nupkg). + 2. Extracts it into the LOWERCASE archive root + microsoft.aspnetcore.app.runtime.{rid}/Release/ -- this directory name + is a load-bearing contract for the future dotnet/crank aspnetcore + overlay path, which mirrors BuildCacheClient's + FindDirectory(extractDir, "microsoft.aspnetcore.app.runtime.{rid}"). + 3. Validates the managed Microsoft.AspNetCore.*.dll assemblies landed under + runtimes/{rid}/lib and strips any stray *.pdb / *.dbg files. + 4. Compresses the lowercase root directory into the per-config archive. + 5. Asserts the archive's single root entry matches the contract. + +.PARAMETER Rids + One or more .NET RIDs to pack (e.g. win-x64, win-x86, win-arm64, linux-x64, + linux-arm64). os/arch and the BCS naming are derived from each RID. + +.PARAMETER Format + Archive format: 'zip' (Windows convention) or 'targz' (Linux convention). + +.PARAMETER ShippingDir + Directory containing the runtime-pack nupkgs. Defaults to + $(Build.SourcesDirectory)/artifacts/packages/Release/Shipping so the + pipeline can call this with no path arguments; override it for local runs. + +.PARAMETER StagingRoot + Directory under which the per-config archive staging folders are created. + Defaults to $(Build.ArtifactStagingDirectory)/bcs. + +.EXAMPLE + ./pack-bcs-archives.ps1 -Rids win-x64,win-x86,win-arm64 -Format zip + +.EXAMPLE + ./pack-bcs-archives.ps1 -Rids linux-x64 -Format targz +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string[]]$Rids, + + [Parameter(Mandatory = $true)] + [ValidateSet('zip', 'targz')] + [string]$Format, + + [string]$ShippingDir = (Join-Path $env:BUILD_SOURCESDIRECTORY 'artifacts/packages/Release/Shipping'), + + [string]$StagingRoot = (Join-Path $env:BUILD_ARTIFACTSTAGINGDIRECTORY 'bcs') +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +Add-Type -AssemblyName System.IO.Compression.FileSystem + +function Get-RidParts { + param([string]$Rid) + + $dash = $Rid.IndexOf('-') + if ($dash -lt 1) { + throw "RID '$Rid' is not in the expected '-' form." + } + + $osToken = $Rid.Substring(0, $dash) + $arch = $Rid.Substring($dash + 1) + $os = switch ($osToken) { + 'win' { 'windows' } + 'linux' { 'linux' } + 'osx' { 'osx' } + default { throw "Unsupported RID os token '$osToken' in RID '$Rid'." } + } + + return [pscustomobject]@{ Os = $os; Arch = $arch } +} + +if (-not (Test-Path $ShippingDir)) { + throw "Shipping directory '$ShippingDir' does not exist." +} + +New-Item -ItemType Directory -Force -Path $StagingRoot | Out-Null + +foreach ($rid in $Rids) { + $parts = Get-RidParts -Rid $rid + $os = $parts.Os + $arch = $parts.Arch + $configKey = "aspnetcore_${arch}_${os}" + $artifactName = "BuildArtifacts_${os}_${arch}_Release_aspnetcore" + $ext = if ($Format -eq 'zip') { 'zip' } else { 'tar.gz' } + $archiveFile = "${artifactName}.${ext}" + + Write-Host '' + Write-Host "=== Packing $configKey (rid=$rid, format=$Format) ===" + + $pattern = "Microsoft.AspNetCore.App.Runtime.$rid.*.nupkg" + $nupkg = Get-ChildItem -Path $ShippingDir -Filter $pattern -File -ErrorAction SilentlyContinue | + Where-Object { $_.Name -notmatch '\.symbols\.nupkg$' } | + Sort-Object Name | + Select-Object -First 1 + if (-not $nupkg) { + throw "Could not find runtime pack nupkg matching '$pattern' under '$ShippingDir'." + } + Write-Host "Found nupkg: $($nupkg.FullName)" + + # microsoft.aspnetcore.app.runtime.{rid}/Release/ <-- archive root (lowercase!) + $stageDir = Join-Path $StagingRoot $artifactName + $payloadDir = Join-Path $stageDir "microsoft.aspnetcore.app.runtime.$rid" + $releaseDir = Join-Path $payloadDir 'Release' + if (Test-Path $payloadDir) { + Remove-Item -LiteralPath $payloadDir -Recurse -Force + } + New-Item -ItemType Directory -Force -Path $releaseDir | Out-Null + + # Extract the nupkg (it's a zip) into Release/. Using the .NET API keeps the + # extraction identical on Windows and Linux agents (no unzip/Expand-Archive split). + [System.IO.Compression.ZipFile]::ExtractToDirectory($nupkg.FullName, $releaseDir) + + $runtimesDir = Join-Path $releaseDir "runtimes/$rid" + if (-not (Test-Path $runtimesDir)) { + throw "Extracted runtime pack is missing expected directory '$runtimesDir'." + } + + # Sanity-check that managed assemblies landed where we expect. + $libMatches = @(Get-ChildItem -Path (Join-Path $runtimesDir 'lib') -Recurse -Filter 'Microsoft.AspNetCore.*.dll' -ErrorAction SilentlyContinue) + if ($libMatches.Count -eq 0) { + throw "Extracted runtime pack for $rid is missing managed Microsoft.AspNetCore.*.dll under 'runtimes/$rid/lib'." + } + Write-Host "Validated managed lib dir contains $($libMatches.Count) Microsoft.AspNetCore.*.dll(s)." + + # Defensive: strip debug-symbol files that occasionally tag along. + Get-ChildItem -Path $runtimesDir -Recurse -Include '*.pdb', '*.dbg' -ErrorAction SilentlyContinue | + ForEach-Object { Remove-Item -LiteralPath $_.FullName -Force } + + $archivePath = Join-Path $stageDir $archiveFile + if (Test-Path $archivePath) { + Remove-Item -LiteralPath $archivePath -Force + } + + $expectedRoot = "microsoft.aspnetcore.app.runtime.$rid/" + + if ($Format -eq 'zip') { + # Zip the lowercase root directory so the archive contents start with + # `microsoft.aspnetcore.app.runtime.{rid}/...`. + Compress-Archive -Path $payloadDir -DestinationPath $archivePath -CompressionLevel Optimal -Force + + # Assert the archive root matches the load-bearing contract. + $zip = [System.IO.Compression.ZipFile]::OpenRead($archivePath) + try { + $rootEntries = @($zip.Entries | ForEach-Object { ($_.FullName -split '/')[0] } | Sort-Object -Unique) + if ($rootEntries.Count -ne 1 -or "$($rootEntries[0])/" -ne $expectedRoot) { + throw "Archive '$archivePath' has root entries [$($rootEntries -join ', ')] but expected exactly '$expectedRoot' (load-bearing contract for future crank PR)." + } + } + finally { + $zip.Dispose() + } + } + else { + # Tar the lowercase root directory (so the archive contents start with + # `microsoft.aspnetcore.app.runtime.{rid}/...`). + & tar -czf $archivePath -C $stageDir "microsoft.aspnetcore.app.runtime.$rid" + if ($LASTEXITCODE -ne 0) { + throw "tar of '$payloadDir' failed (exit $LASTEXITCODE)." + } + + # Assert the archive root matches the load-bearing contract. + $firstEntry = (& tar -tzf $archivePath | Select-Object -First 1) + if (-not $firstEntry.StartsWith($expectedRoot)) { + throw "Archive '$archivePath' first entry '$firstEntry' does not start with '$expectedRoot' (load-bearing contract for future crank PR)." + } + } + + # Drop the working payload directory so only the archive ends up in the published pipeline artifact. + Remove-Item -LiteralPath $payloadDir -Recurse -Force + + $size = (Get-Item $archivePath).Length + Write-Host "Produced archive: $archivePath ($size bytes)" +}