Sync eng/common directory with azure-sdk-tools for PR 16660 (#3428)

* Add Get-PackageApprovalStatus script.

* Sync eng/common directory with azure-sdk-tools repository for Tools PR 16660

* Sync eng/common directory with azure-sdk-tools repository for Tools PR 16660

* Sync eng/common directory with azure-sdk-tools repository for Tools PR 16660

* Sync eng/common directory with azure-sdk-tools repository for Tools PR 16660

* Sync eng/common directory with azure-sdk-tools repository for Tools PR 16660

---------

Co-authored-by: Travis Prescott <trpresco@microsoft.com>
Co-authored-by: azure-sdk <azuresdk@microsoft.com>
This commit is contained in:
authored and GitHub committed 2026-08-31 09:06:53 -07:00
1 parent e4bf9eb6dc
commit 6865d70a8d
12 files changed
+1364 -2

No files matched your search

@@ -57,9 +57,23 @@ steps:
-BuildId '$(Build.BuildId)'
-RepoName '$(Build.Repository.Name)'
-MarkPackageAsShipped $${{parameters.MarkPackageAsShipped}}
displayName: Create API Review
displayName: Create and Validate API Review
condition: >-
and(
succeededOrFailed(),
ne(variables['Skip.CreateApiReview'], 'true')
not(
and(
eq(variables['Skip.CreateApiReview'], 'true'),
in(
lower(variables['Build.RequestedForEmail']),
'bebroder@microsoft.com',
'mharder@microsoft.com',
'djurek@microsoft.com',
'chononiw@microsoft.com',
'raychen@microsoft.com',
'trpresco@microsoft.com',
'prmarott@microsoft.com'
)
)
)
)
@@ -0,0 +1,66 @@
parameters:
- name: ArtifactPath
type: string
default: $(Build.ArtifactStagingDirectory)
- name: Artifacts
type: object
default: []
- name: ConfigFileDir
type: string
default: $(Build.ArtifactStagingDirectory)/PackageInfo
- name: GenerateApiReviewForManualOnly
type: boolean
default: false
- name: ArtifactName
type: string
default: "packages"
- name: PackageName
type: string
default: ""
- name: SourceRootPath
type: string
default: $(Build.SourcesDirectory)
- name: PackageInfoFiles
type: object
default: []
steps:
# Automatic APIView revisions are created regardless of how the pipeline is triggered.
# This condition limits revision creation to manual runs when GenerateApiReviewForManualOnly is true.
- ${{ if or(ne(parameters.GenerateApiReviewForManualOnly, true), eq(variables['Build.Reason'], 'Manual')) }}:
# Ideally this should be an initial step in the caller job.
# Remove this step once all callers set the default branch themselves.
- template: /eng/common/pipelines/templates/steps/set-default-branch.yml
parameters:
WorkingDirectory: ${{ parameters.SourceRootPath }}
- ${{ if and(eq(variables['System.TeamProject'], 'internal'), ne(variables['Build.Reason'], 'PullRequest'), not(endsWith(variables['Build.Repository.Name'], '-pr'))) }}:
- task: AzureCLI@2
inputs:
azureSubscription: "APIView prod deployment"
scriptType: pscore
scriptLocation: scriptPath
scriptPath: ${{ parameters.SourceRootPath }}/eng/common/scripts/Create-APIViewRevision.ps1
# PackageInfoFiles example: @('a/file1.json','a/file2.json')
arguments: >
-PackageInfoFiles @('${{ join(''',''', parameters.PackageInfoFiles) }}')
-ArtifactList ('${{ convertToJson(parameters.Artifacts) }}' | ConvertFrom-Json | Select-Object Name)
-ArtifactPath '${{ parameters.ArtifactPath }}'
-ArtifactName '${{ parameters.ArtifactName }}'
-PackageName '${{ parameters.PackageName }}'
-SourceBranch '$(Build.SourceBranchName)'
-DefaultBranch '$(DefaultBranch)'
-ConfigFileDir '${{ parameters.ConfigFileDir }}'
-BuildId '$(Build.BuildId)'
-RepoName '$(Build.Repository.Name)'
displayName: Create APIView Revision
condition: >-
and(
succeededOrFailed(),
not(
and(
eq(variables['Skip.CreateApiReview'], 'true'),
eq(variables['IsRequesterAuthorizedToSkipApiReview'], 'true')
)
)
)
@@ -0,0 +1,45 @@
parameters:
- name: PackageInfoFiles
type: object
- name: RepoOwner
type: string
default: ""
- name: SourceRootPath
type: string
default: $(Build.SourcesDirectory)
steps:
# The consuming pipeline must install the Azure SDK Tools CLI before including this template.
# Use install-azsdk-cli.yml or otherwise set $(AZSDK) to the azsdk executable path.
- task: AzureCLI@2
displayName: Check Package Approval Status
# Keep this predicate aligned with the release-stage inclusion condition in archetype-python-release.yml.
condition: >-
and(
succeeded(),
eq(variables['System.TeamProject'], 'internal'),
or(
in(variables['Build.Reason'], 'Manual', ''),
and(
eq(variables['Build.Reason'], 'IndividualCI'),
eq(variables['Build.SourceBranch'], 'refs/heads/main')
)
),
not(
and(
eq(variables['Skip.CheckPackageApproval'], 'true'),
eq(variables['IsRequesterAuthorizedToSkipApiReview'], 'true')
)
)
)
inputs:
azureSubscription: "ADO to ARH Service Connection"
scriptType: pscore
scriptLocation: scriptPath
scriptPath: ${{ parameters.SourceRootPath }}/eng/common/scripts/Get-PackageApprovalStatus.ps1
# PackageInfoFiles example: @('a/file1.json','a/file2.json')
arguments: >
-PackageInfoFiles @('${{ join(''',''', parameters.PackageInfoFiles) }}')
-RepoOwner '${{ parameters.RepoOwner }}'
-AzSdkExePath '$(AZSDK)'
workingDirectory: $(Pipeline.Workspace)
@@ -0,0 +1,37 @@
parameters:
- name: PackageInfoFiles
type: object
- name: RepoOwner
type: string
default: ""
- name: SourceRootPath
type: string
default: $(Build.SourcesDirectory)
steps:
# Include this template only after package publishing in the release stage.
# The consuming pipeline must install the Azure SDK Tools CLI before including this template.
# Use install-azsdk-cli.yml or otherwise set $(AZSDK) to the azsdk executable path.
- task: AzureCLI@2
displayName: Mark Packages Released
condition: >-
and(
succeeded(),
not(
and(
eq(variables['Skip.MarkPackageReleased'], 'true'),
eq(variables['IsRequesterAuthorizedToSkipApiReview'], 'true')
)
)
)
inputs:
azureSubscription: "ADO to ARH Service Connection"
scriptType: pscore
scriptLocation: scriptPath
scriptPath: ${{ parameters.SourceRootPath }}/eng/common/scripts/Mark-PackageReleased.ps1
# PackageInfoFiles example: @('a/file1.json','a/file2.json')
arguments: >
-PackageInfoFiles @('${{ join(''',''', parameters.PackageInfoFiles) }}')
-RepoOwner '${{ parameters.RepoOwner }}'
-AzSdkExePath '$(AZSDK)'
workingDirectory: $(Pipeline.Workspace)
@@ -0,0 +1,12 @@
variables:
IsRequesterAuthorizedToSkipApiReview: >-
$[in(
lower(variables['Build.RequestedForEmail']),
'bebroder@microsoft.com',
'mharder@microsoft.com',
'djurek@microsoft.com',
'chononiw@microsoft.com',
'raychen@microsoft.com',
'trpresco@microsoft.com',
'prmarott@microsoft.com'
)]
@@ -0,0 +1,285 @@
<#
.SYNOPSIS
Creates APIView revisions for SDK packages.
.DESCRIPTION
Uploads package artifacts directly to APIView or creates revisions from pre-generated review token files.
#>
[CmdletBinding()]
param (
[Parameter(Mandatory = $false)]
[array] $ArtifactList,
[Parameter(Mandatory = $true)]
[ValidateNotNullOrEmpty()]
[string] $ArtifactPath,
[string] $SourceBranch,
[string] $DefaultBranch,
[string] $RepoName,
[string] $BuildId,
[string] $PackageName = "",
[string] $ConfigFileDir = "",
[string] $APIViewUri = "https://apiview.dev/autoreview",
[string] $ArtifactName = "packages",
[Parameter(Mandatory = $false)]
[array] $PackageInfoFiles
)
Set-StrictMode -Version 4
$ErrorActionPreference = "Stop"
# Loads LanguageShort from eng/scripts/Language-Settings.ps1.
. (Join-Path $PSScriptRoot common.ps1)
function Get-ApiViewBearerToken {
$tokenResponse = az account get-access-token --resource "api://apiview" --output json 2>&1
if ($LASTEXITCODE -ne 0) {
throw "Failed to acquire APIView access token: $tokenResponse"
}
try {
$accessToken = ($tokenResponse | ConvertFrom-Json -ErrorAction Stop).accessToken
}
catch {
throw "Failed to parse APIView access token response: $($_.Exception.Message)"
}
if ([string]::IsNullOrWhiteSpace($accessToken)) {
throw "APIView access token response did not contain an access token."
}
return $accessToken
}
function Invoke-ApiViewSourceUpload(
[string] $FilePath,
[string] $ApiLabel,
[string] $ReleaseStatus,
[string] $PackageVersion,
[string] $PackageType
) {
$fileName = Split-Path -Leaf $FilePath
Write-Host "Uploading $FilePath to APIView."
$multipartContent = [System.Net.Http.MultipartFormDataContent]::new()
$fileStream = $null
try {
$fileStream = [System.IO.FileStream]::new($FilePath, [System.IO.FileMode]::Open)
$fileHeader = [System.Net.Http.Headers.ContentDispositionHeaderValue]::new("form-data")
$fileHeader.Name = "file"
$fileHeader.FileName = $fileName
$fileContent = [System.Net.Http.StreamContent]::new($fileStream)
$fileContent.Headers.ContentDisposition = $fileHeader
$fileContent.Headers.ContentType = [System.Net.Http.Headers.MediaTypeHeaderValue]::Parse("application/octet-stream")
$multipartContent.Add($fileContent)
Add-MultipartString $multipartContent "label" $ApiLabel
Add-MultipartString $multipartContent "packageVersion" $PackageVersion
Add-MultipartString $multipartContent "setReleaseTag" "False"
Add-MultipartString $multipartContent "packageType" $PackageType
if ($ReleaseStatus -and $ReleaseStatus -ne "Unreleased") {
Add-MultipartString $multipartContent "compareAllRevisions" "True"
}
$headers = @{ Authorization = "Bearer $(Get-ApiViewBearerToken)" }
$response = Invoke-WebRequest -Method Post -Uri "$APIViewUri/upload" -Body $multipartContent -Headers $headers -MaximumRetryCount 3
Write-Host "API review: $($response.Content)"
Write-Host "HTTP response code: $($response.StatusCode)"
}
finally {
$multipartContent.Dispose()
if ($null -ne $fileStream) {
$fileStream.Dispose()
}
}
}
function Add-MultipartString(
[System.Net.Http.MultipartFormDataContent] $MultipartContent,
[string] $Name,
[string] $Value
) {
$header = [System.Net.Http.Headers.ContentDispositionHeaderValue]::new("form-data")
$header.Name = $Name
$content = [System.Net.Http.StringContent]::new($Value)
$content.Headers.ContentDisposition = $header
$MultipartContent.Add($content)
Write-Host "Request param, ${Name}: $Value"
}
function Invoke-ApiViewTokenCreation(
[object] $PackageInfo,
[string] $ApiLabel,
[string] $ReviewFileName,
[string] $PackagePath
) {
if ([string]::IsNullOrWhiteSpace($BuildId)) {
throw "BuildId is required to create an APIView revision from a review token file."
}
if ([string]::IsNullOrWhiteSpace($RepoName)) {
throw "RepoName is required to create an APIView revision from a review token file."
}
$fileName = Split-Path -Leaf $PackagePath
$queryParameters = [ordered]@{
buildId = $BuildId
artifactName = $ArtifactName
originalFilePath = $fileName
reviewFilePath = $ReviewFileName
label = $ApiLabel
repoName = $RepoName
packageName = $PackageInfo.ArtifactName
project = "internal"
packageVersion = $PackageInfo.Version
packageType = $PackageInfo.SdkType
}
if ($PackageInfo.ReleaseStatus -and $PackageInfo.ReleaseStatus -ne "Unreleased") {
$queryParameters["compareAllRevisions"] = "true"
}
$query = @($queryParameters.GetEnumerator() | ForEach-Object {
"$([System.Uri]::EscapeDataString($_.Key))=$([System.Uri]::EscapeDataString($_.Value))"
}) -join "&"
$uri = "$APIViewUri/create?$query"
Write-Host "Creating APIView revision from review token file $ReviewFileName."
Write-Host "Request to APIView: $uri"
$headers = @{ Authorization = "Bearer $(Get-ApiViewBearerToken)" }
$response = Invoke-WebRequest -Method Post -Uri $uri -Headers $headers -MaximumRetryCount 3
Write-Host "API review: $($response.Content)"
Write-Host "HTTP response code: $($response.StatusCode)"
}
function Get-ApiReviewTokenFileName([string] $ArtifactName) {
$reviewTokenFileName = "${ArtifactName}_${LanguageShort}.json"
$tokenFilePath = Join-Path $ArtifactPath $ArtifactName $reviewTokenFileName
if (Test-Path $tokenFilePath) {
Write-Host "Review token file is present at $tokenFilePath"
return $reviewTokenFileName
}
Write-Host "Review token file is not present at $tokenFilePath"
return $null
}
function Submit-ApiViewRevision([object] $PackageInfo, [string] $PackagePath) {
$apiLabel = "Source Branch:$SourceBranch"
$reviewTokenFileName = Get-ApiReviewTokenFileName $PackageInfo.ArtifactName
if ($reviewTokenFileName) {
Invoke-ApiViewTokenCreation $PackageInfo $apiLabel $reviewTokenFileName $PackagePath
return
}
Invoke-ApiViewSourceUpload $PackagePath $apiLabel $PackageInfo.ReleaseStatus $PackageInfo.Version $PackageInfo.SdkType
}
function Find-PackageArtifacts([object] $PackageInfo) {
if (-not $FindArtifactForApiReviewFn -or -not (Test-Path "Function:$FindArtifactForApiReviewFn")) {
throw "The function configured by 'FindArtifactForApiReviewFn' was not found."
}
$artifactName = if ($PackageInfo.ArtifactName) { $PackageInfo.ArtifactName } else { $PackageInfo.Name }
$functionInfo = Get-Command $FindArtifactForApiReviewFn -ErrorAction Stop
if ($functionInfo.Parameters.Keys -contains "packageInfo") {
return &$FindArtifactForApiReviewFn $ArtifactPath $PackageInfo
}
return &$FindArtifactForApiReviewFn $ArtifactPath $artifactName
}
function Process-Package([object] $PackageInfo) {
$packages = Find-PackageArtifacts $PackageInfo
if (-not $packages) {
Write-Host "No package is found in artifact path to submit a review request for $($PackageInfo.ArtifactName)."
return
}
$version = [AzureEngSemanticVersion]::ParseVersionString($PackageInfo.Version)
if ($null -eq $version) {
throw "Version '$($PackageInfo.Version)' for package $($PackageInfo.ArtifactName) is invalid."
}
Write-Host "Version: $version"
Write-Host "SDK Type: $($PackageInfo.SdkType)"
Write-Host "Release Status: $($PackageInfo.ReleaseStatus)"
if ($SourceBranch -ne $DefaultBranch -and $version.IsPrerelease) {
Write-Host "Build is triggered from $SourceBranch with a prerelease version. Skipping APIView revision creation."
return
}
foreach ($packagePath in $packages.Values) {
Write-Host "Submitting APIView revision for package $($PackageInfo.ArtifactName), file path: $packagePath"
Submit-ApiViewRevision $PackageInfo $packagePath
}
}
function Resolve-PackageInfoFiles {
if (-not $ConfigFileDir) {
$script:ConfigFileDir = Join-Path $ArtifactPath "PackageInfo"
}
if ($PackageName) {
$packageInfoPath = Join-Path $ConfigFileDir "$PackageName.json"
if (-not (Test-Path $packageInfoPath)) {
throw "Package property file path $packageInfoPath is invalid."
}
return @($packageInfoPath)
}
if ($ArtifactList -and $ArtifactList.Count -gt 0) {
$files = @()
foreach ($artifact in $ArtifactList) {
$packageInfoPath = Join-Path $ConfigFileDir "$($artifact.Name).json"
if (Test-Path $packageInfoPath) {
$files += $packageInfoPath
}
else {
Write-Warning "Package property file path $packageInfoPath is invalid."
}
}
return $files
}
if ($PackageInfoFiles -and $PackageInfoFiles.Count -gt 0) {
return @($PackageInfoFiles | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
}
throw "No package information provided. Provide PackageName, ArtifactList, or PackageInfoFiles."
}
Write-Host "Artifact path: $ArtifactPath"
Write-Host "Source branch: $SourceBranch"
Write-Host "Package name: $PackageName"
if ([string]::IsNullOrWhiteSpace($SourceBranch)) {
throw "SourceBranch is required to determine APIView revision eligibility."
}
if ([string]::IsNullOrWhiteSpace($DefaultBranch)) {
throw "DefaultBranch is required to determine APIView revision eligibility."
}
if (-not (Test-Path $ArtifactPath -PathType Container)) {
throw "Artifact path $ArtifactPath does not exist or is not a directory."
}
$processedPackageInfoFiles = @(Resolve-PackageInfoFiles)
if ($processedPackageInfoFiles.Count -eq 0) {
throw "No package info files found after processing the supplied package inputs."
}
$failures = @()
foreach ($packageInfoFile in $processedPackageInfoFiles) {
try {
$packageInfo = Get-Content $packageInfoFile -Raw | ConvertFrom-Json -ErrorAction Stop
Write-Host "Processing $($packageInfo.ArtifactName)"
Process-Package $packageInfo
}
catch {
Write-Error "Failed to create an APIView revision from ${packageInfoFile}: $($_.Exception.Message)" -ErrorAction Continue
$failures += "${packageInfoFile}: $($_.Exception.Message)"
}
}
if ($failures.Count -gt 0) {
throw "APIView revision creation failed for $($failures.Count) package(s):`n$($failures -join "`n")"
}
@@ -0,0 +1,194 @@
<#
.SYNOPSIS
Checks whether a package is approved for release.
.DESCRIPTION
Invokes the centralized Azure SDK CLI API review release gate and fails unless its structured result approves the package.
.PARAMETER PackageInfoFiles
Package-info JSON files containing the package name, version, and optional API hash.
.PARAMETER RepoOwner
The optional GitHub repository owner to query in API Review Hub.
.PARAMETER AzSdkExePath
The path to the azsdk executable.
#>
[CmdletBinding()]
param (
[Parameter(Mandatory = $true)]
[ValidateNotNullOrEmpty()]
[array] $PackageInfoFiles,
[string] $RepoOwner = "",
[ValidateNotNullOrEmpty()]
[string] $AzSdkExePath = "azsdk"
)
Set-StrictMode -Version 4
$ErrorActionPreference = "Stop"
. (Join-Path $PSScriptRoot common.ps1)
if ([string]::IsNullOrWhiteSpace($LanguageShort) -or $LanguageShort -eq "Unknown") {
throw "SDK language settings were not loaded."
}
Write-Host "SDK language: $LanguageShort"
function Write-BackendStatus([string] $Name, [object] $Status) {
if ($null -eq $Status) {
return
}
$approval = if ($Status.isApproved) { "APPROVED" } else { "NOT APPROVED" }
Write-Host $Name
Write-Host " Status: $approval"
if ($Status.PSObject.Properties["reason"]) {
Write-Host " Reason: $($Status.reason)"
}
if ($Status.PSObject.Properties["statusCode"]) {
Write-Host " HTTP status: $($Status.statusCode)"
}
if ($Status.PSObject.Properties["details"]) {
foreach ($detail in @($Status.details)) {
Write-Host " Detail: $detail"
}
}
}
function Write-ApprovalSummary([object] $Response) {
$result = $Response.result
$source = if ($result.PSObject.Properties["finalSource"]) { $result.finalSource } else { "unknown" }
$reason = if ($result.PSObject.Properties["reason"]) { $result.reason } else { "none" }
Write-Host ""
Write-Host "Approval results"
Write-Host "----------------"
if ($result.PSObject.Properties["reviewHub"]) {
Write-BackendStatus "API Review Hub" $result.reviewHub
}
if ($result.PSObject.Properties["apiView"]) {
Write-Host ""
Write-BackendStatus "APIView" $result.apiView
}
$approval = if ($result.isApproved) { "APPROVED" } else { "NOT APPROVED" }
Write-Host ""
Write-Host "Overall"
Write-Host " Status: $approval"
Write-Host " Source: $source"
Write-Host " Reason: $reason"
}
function Test-PackageApproval([string] $PackageName, [string] $PackageVersion, [string] $ApiHash) {
$arguments = @(
"package",
"get-approval-status",
"--language", $LanguageShort,
"--package-name", $PackageName,
"--package-version", $PackageVersion,
"--output", "json"
)
if (-not [string]::IsNullOrWhiteSpace($ApiHash)) {
$arguments += @("--api-hash", $ApiHash)
}
if (-not [string]::IsNullOrWhiteSpace($RepoOwner)) {
$arguments += @("--repo-owner", $RepoOwner)
}
$hashDescription = if ([string]::IsNullOrWhiteSpace($ApiHash)) { "not provided" } else { $ApiHash }
Write-Host "Checking package approval: language=$LanguageShort, package=$PackageName, version=$PackageVersion, apiHash=$hashDescription"
$formattedArguments = @($arguments | ForEach-Object { Format-CommandArgument $_ })
Write-Host "Command: azsdk $($formattedArguments -join ' ')"
$commandResult = Invoke-AzSdkCliCommand $AzSdkExePath $arguments
$exitCode = $commandResult.ExitCode
try {
$response = $commandResult.Output | ConvertFrom-Json -ErrorAction Stop
}
catch {
$capturedOutput = "stdout:`n$($commandResult.Stdout)`nstderr:`n$($commandResult.Stderr)"
throw "Package approval check returned malformed JSON for $PackageName $PackageVersion (azsdk exit code $exitCode). Captured output:`n$capturedOutput"
}
if ($response.PSObject.Properties["result"] -and
$null -ne $response.result -and
$response.result.PSObject.Properties["isApproved"] -and
$response.result.isApproved -is [bool]) {
Write-ApprovalSummary $response
}
if ($exitCode -ne 0) {
$failureMessage = if ($response.PSObject.Properties["response_error"] -and
-not [string]::IsNullOrWhiteSpace($response.response_error)) {
$response.response_error
} else {
"azsdk exited with code $exitCode."
}
throw "Package approval check failed: $failureMessage"
}
if (-not $response.PSObject.Properties["result"] -or
$null -eq $response.result -or
-not $response.result.PSObject.Properties["isApproved"] -or
$response.result.isApproved -isnot [bool]) {
throw "Package approval check returned an invalid response for $PackageName $PackageVersion."
}
if (-not $response.result.isApproved) {
throw "Package $PackageName $PackageVersion is not approved for release."
}
}
$packageInfoPaths = @($PackageInfoFiles | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
if ($packageInfoPaths.Count -eq 0) {
throw "At least one package-info file is required."
}
Confirm-AzSdkCliMinimumVersion $AzSdkExePath ([version] "0.6.38")
$failures = @()
foreach ($packageInfoFile in $packageInfoPaths) {
try {
if (-not (Test-Path $packageInfoFile -PathType Leaf)) {
throw "Package-info file does not exist."
}
$packageInfo = Get-Content $packageInfoFile -Raw | ConvertFrom-Json -ErrorAction Stop
$packageName = if ($packageInfo.PSObject.Properties["Name"]) { [string] $packageInfo.Name } else { "" }
$packageVersion = if ($packageInfo.PSObject.Properties["Version"]) { [string] $packageInfo.Version } else { "" }
$apiHash = if ($packageInfo.PSObject.Properties["ApiHash"]) { [string] $packageInfo.ApiHash } else { "" }
$releaseStatus = if ($packageInfo.PSObject.Properties["ReleaseStatus"]) { [string] $packageInfo.ReleaseStatus } else { "" }
if ([string]::IsNullOrWhiteSpace($packageName)) {
throw "Package-info file does not contain a package Name."
}
if ([string]::IsNullOrWhiteSpace($packageVersion)) {
throw "Package-info file does not contain a package Version."
}
try {
Test-PackageApproval $packageName $packageVersion $apiHash
}
catch {
if ($releaseStatus -eq "Unreleased") {
Write-Host "$packageName $packageVersion is not marked for release. Ignoring approval check failure: $($_.Exception.Message)"
}
else {
throw
}
}
}
catch {
Write-Error "Package approval failed for ${packageInfoFile}: $($_.Exception.Message)" -ErrorAction Continue
$failures += "${packageInfoFile}: $($_.Exception.Message)"
}
}
if ($failures.Count -gt 0) {
throw "Package approval failed for $($failures.Count) package(s):`n$($failures -join "`n")"
}
@@ -1,5 +1,73 @@
. $PSScriptRoot/../logging.ps1
function Format-CommandArgument([string] $Argument) {
if ($Argument -match '[\s"'']') {
return '"' + $Argument.Replace('"', '\"') + '"'
}
return $Argument
}
function Invoke-AzSdkCliCommand([string] $Executable, [string[]] $Arguments) {
$command = Get-Command $Executable -ErrorAction SilentlyContinue
if (-not $command) {
throw "The azsdk CLI executable was not found at '$Executable'. Install azsdk before continuing."
}
if ($command.CommandType -ne [System.Management.Automation.CommandTypes]::Application) {
$output = @(& $command @Arguments 2>&1)
return [PSCustomObject]@{
ExitCode = $LASTEXITCODE
Output = ($output | ForEach-Object { "$_" }) -join [Environment]::NewLine
Stdout = ($output | ForEach-Object { "$_" }) -join [Environment]::NewLine
Stderr = ""
}
}
$startInfo = [System.Diagnostics.ProcessStartInfo]::new()
$startInfo.FileName = $command.Source
$startInfo.UseShellExecute = $false
$startInfo.RedirectStandardOutput = $true
$startInfo.RedirectStandardError = $true
$startInfo.CreateNoWindow = $true
if ($startInfo.PSObject.Properties["ArgumentList"]) {
foreach ($argument in $Arguments) {
$startInfo.ArgumentList.Add($argument)
}
}
else {
$formattedArguments = @($Arguments | ForEach-Object { Format-CommandArgument $_ })
$startInfo.Arguments = $formattedArguments -join " "
}
$process = [System.Diagnostics.Process]::Start($startInfo)
$stdoutTask = $process.StandardOutput.ReadToEndAsync()
$stderrTask = $process.StandardError.ReadToEndAsync()
$process.WaitForExit()
$stdout = $stdoutTask.GetAwaiter().GetResult()
$stderr = $stderrTask.GetAwaiter().GetResult()
return [PSCustomObject]@{
ExitCode = $process.ExitCode
Output = if (-not [string]::IsNullOrWhiteSpace($stdout)) { $stdout } else { $stderr }
Stdout = $stdout
Stderr = $stderr
}
}
function Confirm-AzSdkCliMinimumVersion([string] $Executable, [version] $MinimumVersion) {
$commandResult = Invoke-AzSdkCliCommand $Executable @("--version")
$versionMatch = [regex]::Match($commandResult.Output, '(?<!\d)\d+\.\d+\.\d+(?:\.\d+)?(?!\d)')
if ($commandResult.ExitCode -ne 0 -or -not $versionMatch.Success) {
throw "Unable to determine the azsdk CLI version. Run 'azsdk --version' to verify the installation."
}
$installedVersion = [version] $versionMatch.Value
if ($installedVersion -lt $MinimumVersion) {
throw "azsdk CLI version $MinimumVersion or later is required; found $installedVersion."
}
}
function Invoke-LoggedMsbuildCommand
{
[CmdletBinding()]
+123
View File
@@ -0,0 +1,123 @@
<#
.SYNOPSIS
Marks a published package as released in API Review Hub and APIView.
.DESCRIPTION
Invokes the centralized Azure SDK CLI release-completion command and surfaces each backend result.
.PARAMETER PackageInfoFiles
Package-info JSON files containing the published package name, version, and API hash.
.PARAMETER AzSdkExePath
The path to the azsdk executable.
#>
[CmdletBinding()]
param (
[Parameter(Mandatory = $true)]
[ValidateNotNullOrEmpty()]
[array] $PackageInfoFiles,
[string] $RepoOwner = "",
[ValidateNotNullOrEmpty()]
[string] $AzSdkExePath = "azsdk"
)
Set-StrictMode -Version 4
$ErrorActionPreference = "Stop"
. (Join-Path $PSScriptRoot common.ps1)
function Write-BackendResult([string] $Name, [object] $Result) {
Write-Host $Name
Write-Host " Result: $($Result | ConvertTo-Json -Compress -Depth 20)"
}
function Set-PackageReleased([string] $PackageName, [string] $PackageVersion, [string] $ApiHash) {
$arguments = @(
"package",
"mark-released",
"--language", $LanguageShort,
"--package-name", $PackageName,
"--package-version", $PackageVersion
)
if (-not [string]::IsNullOrWhiteSpace($ApiHash)) {
$arguments += @("--api-hash", $ApiHash)
}
$arguments += @("--output", "json")
if (-not [string]::IsNullOrWhiteSpace($RepoOwner)) {
$arguments += @("--repo-owner", $RepoOwner)
}
$hashDescription = if ([string]::IsNullOrWhiteSpace($ApiHash)) { "not provided" } else { $ApiHash }
Write-Host "Marking package released: language=$LanguageShort, package=$PackageName, version=$PackageVersion, apiHash=$hashDescription"
$formattedArguments = @($arguments | ForEach-Object { Format-CommandArgument $_ })
Write-Host "Command: azsdk $($formattedArguments -join ' ')"
$commandResult = Invoke-AzSdkCliCommand $AzSdkExePath $arguments
$exitCode = $commandResult.ExitCode
try {
$response = $commandResult.Output | ConvertFrom-Json -ErrorAction Stop
}
catch {
$capturedOutput = "stdout:`n$($commandResult.Stdout)`nstderr:`n$($commandResult.Stderr)"
throw "Mark released returned malformed JSON for $PackageName $PackageVersion (azsdk exit code $exitCode). Captured output:`n$capturedOutput"
}
$hasReviewHubResult = $response.PSObject.Properties["api_review_hub"] -and $null -ne $response.api_review_hub
$hasApiViewResult = $response.PSObject.Properties["api_view"] -and $null -ne $response.api_view
if ($hasReviewHubResult) {
Write-BackendResult "API Review Hub" $response.api_review_hub
}
if ($hasApiViewResult) {
Write-BackendResult "APIView" $response.api_view
}
if ($exitCode -ne 0) {
[array] $errors = if ($response.PSObject.Properties["response_errors"]) { @($response.response_errors) } else { @() }
$failureMessage = if ($errors.Count -gt 0) { $errors -join "; " } else { "azsdk exited with code $exitCode." }
throw "Mark released failed: $failureMessage"
}
}
$packageInfoPaths = @($PackageInfoFiles | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
if ($packageInfoPaths.Count -eq 0) {
throw "At least one package-info file is required."
}
Confirm-AzSdkCliMinimumVersion $AzSdkExePath ([version] "0.6.38")
$failures = @()
foreach ($packageInfoFile in $packageInfoPaths) {
try {
if (-not (Test-Path $packageInfoFile -PathType Leaf)) {
throw "Package-info file does not exist."
}
$packageInfo = Get-Content $packageInfoFile -Raw | ConvertFrom-Json -ErrorAction Stop
$packageName = if ($packageInfo.PSObject.Properties["Name"]) { [string] $packageInfo.Name } else { "" }
$packageVersion = if ($packageInfo.PSObject.Properties["Version"]) { [string] $packageInfo.Version } else { "" }
$apiHash = if ($packageInfo.PSObject.Properties["ApiHash"]) { [string] $packageInfo.ApiHash } else { "" }
if ([string]::IsNullOrWhiteSpace($packageName)) {
throw "Package-info file does not contain a package Name."
}
if ([string]::IsNullOrWhiteSpace($packageVersion)) {
throw "Package-info file does not contain a package Version."
}
Set-PackageReleased $packageName $packageVersion $apiHash
}
catch {
Write-Error "Mark released failed for ${packageInfoFile}: $($_.Exception.Message)" -ErrorAction Continue
$failures += "${packageInfoFile}: $($_.Exception.Message)"
}
}
if ($failures.Count -gt 0) {
throw "Mark released failed for $($failures.Count) package(s):`n$($failures -join "`n")"
}
@@ -0,0 +1,128 @@
Describe "Create-APIViewRevision.ps1" {
BeforeAll {
$scriptPath = Join-Path (Join-Path $PSScriptRoot "..") "Create-APIViewRevision.ps1"
function global:Find-Unknown-Artifacts-For-Apireview {
param ([string] $ArtifactPath, [string] $ArtifactName)
return @{ $ArtifactName = $global:TestPackagePath }
}
function global:az {
$global:LASTEXITCODE = 0
return '{"accessToken":"test-token"}'
}
function global:Invoke-WebRequest {
param (
[string] $Method,
[string] $Uri,
[object] $Body,
[hashtable] $Headers,
[int] $MaximumRetryCount
)
$global:ApiViewRequests += [PSCustomObject]@{
Method = $Method
Uri = $Uri
Body = if ($null -ne $Body) { $Body.ReadAsStringAsync().GetAwaiter().GetResult() } else { "" }
Headers = $Headers
MaximumRetryCount = $MaximumRetryCount
}
return [PSCustomObject]@{ Content = "created"; StatusCode = 200 }
}
}
AfterAll {
Remove-Item Function:\Find-Unknown-Artifacts-For-Apireview -ErrorAction SilentlyContinue
Remove-Item Function:\az -ErrorAction SilentlyContinue
Remove-Item Function:\Invoke-WebRequest -ErrorAction SilentlyContinue
Remove-Variable TestPackagePath, ApiViewRequests, LanguageShort -Scope Global -ErrorAction SilentlyContinue
}
BeforeEach {
$global:LanguageShort = "Python"
$testRoot = Join-Path $TestDrive "artifacts"
$packageName = "test-package"
$packageDirectory = Join-Path $testRoot $packageName
$packageInfoDirectory = Join-Path $testRoot "PackageInfo"
New-Item -ItemType Directory -Path $packageDirectory, $packageInfoDirectory -Force | Out-Null
$global:TestPackagePath = Join-Path $packageDirectory "$packageName.zip"
Set-Content -Path $global:TestPackagePath -Value "package"
$packageInfoPath = Join-Path $packageInfoDirectory "$packageName.json"
@{
ArtifactName = $packageName
Name = $packageName
Version = "1.0.0"
SdkType = "client"
ReleaseStatus = "Unreleased"
} | ConvertTo-Json | Set-Content $packageInfoPath
$global:ApiViewRequests = @()
}
It "uploads the source artifact when no review token exists" {
& $scriptPath -ArtifactPath $testRoot -PackageName $packageName -SourceBranch main -DefaultBranch main
$global:ApiViewRequests.Count | Should Be 1
$global:ApiViewRequests[0].Uri | Should Be "https://apiview.dev/autoreview/upload"
$global:ApiViewRequests[0].Headers.Authorization | Should Be "Bearer test-token"
$global:ApiViewRequests[0].MaximumRetryCount | Should Be 3
$global:ApiViewRequests[0].Body | Should Match '(?s)name="?label"?.*?Source Branch:main'
$global:ApiViewRequests[0].Body | Should Match '(?s)name="?packageVersion"?.*?1.0.0'
$global:ApiViewRequests[0].Body | Should Match '(?s)name="?setReleaseTag"?.*?False'
$global:ApiViewRequests[0].Body | Should Match '(?s)name="?packageType"?.*?client'
}
It "creates a revision from a review token when one exists" {
Set-Content -Path (Join-Path $packageDirectory "${packageName}_Python.json") -Value "{}"
& $scriptPath -ArtifactPath $testRoot -PackageName $packageName -SourceBranch main -DefaultBranch main -BuildId 123 -RepoName Azure/test
$global:ApiViewRequests.Count | Should Be 1
$global:ApiViewRequests[0].Uri | Should Match "/create\?"
$global:ApiViewRequests[0].Uri | Should Match "buildId=123"
$global:ApiViewRequests[0].Uri | Should Match "repoName=Azure%2ftest"
$global:ApiViewRequests[0].Uri | Should Match "packageName=test-package"
$global:ApiViewRequests[0].Uri | Should Match "reviewFilePath=test-package_Python.json"
$global:ApiViewRequests[0].Uri | Should Not Match "setReleaseTag"
$global:ApiViewRequests[0].MaximumRetryCount | Should Be 3
}
It "requires pipeline metadata when creating from a review token" {
Set-Content -Path (Join-Path $packageDirectory "${packageName}_Python.json") -Value "{}"
$caughtError = $null
try {
& $scriptPath -ArtifactPath $testRoot -PackageName $packageName -SourceBranch main -DefaultBranch main
}
catch {
$caughtError = $_
}
$caughtError.Exception.Message | Should Match "BuildId is required"
$global:ApiViewRequests.Count | Should Be 0
}
It "requires branch metadata before processing packages" {
$caughtError = $null
try {
& $scriptPath -ArtifactPath $testRoot -PackageName $packageName -SourceBranch "" -DefaultBranch main
}
catch {
$caughtError = $_
}
$caughtError.Exception.Message | Should Match "SourceBranch is required"
$global:ApiViewRequests.Count | Should Be 0
}
It "skips prerelease revisions from feature branches" {
$packageInfo = Get-Content $packageInfoPath -Raw | ConvertFrom-Json
$packageInfo.Version = "1.0.0-beta.1"
$packageInfo | ConvertTo-Json | Set-Content $packageInfoPath
& $scriptPath -ArtifactPath $testRoot -PackageName $packageName -SourceBranch feature -DefaultBranch main
$global:ApiViewRequests.Count | Should Be 0
}
}
@@ -0,0 +1,214 @@
Describe "Get-PackageApprovalStatus.ps1" {
BeforeAll {
$scriptPath = Join-Path (Join-Path $PSScriptRoot "..") "Get-PackageApprovalStatus.ps1"
function global:azsdk {
param (
[Parameter(ValueFromRemainingArguments = $true)]
[object[]] $Arguments
)
if ($Arguments.Count -eq 1 -and $Arguments[0] -eq "--version") {
$global:LASTEXITCODE = 0
return $global:AzSdkVersion
}
$global:CapturedAzSdkArguments = @($Arguments)
$global:CapturedAzSdkInvocations += ,@($Arguments)
$global:LASTEXITCODE = $global:AzSdkExitCode
return $global:AzSdkOutput
}
}
AfterAll {
Remove-Item Function:\azsdk -ErrorAction SilentlyContinue
Remove-Variable AzSdkExitCode, AzSdkOutput, AzSdkVersion, CapturedAzSdkArguments, CapturedAzSdkInvocations, LanguageShort -Scope Global -ErrorAction SilentlyContinue
}
BeforeEach {
$global:LanguageShort = "python"
$global:AzSdkExitCode = 0
$global:AzSdkVersion = "0.6.38"
$global:AzSdkOutput = '{"operation_status":"Succeeded","result":{"isApproved":true,"finalSource":"reviewHub","reason":"approved"}}'
$global:CapturedAzSdkArguments = @()
$global:CapturedAzSdkInvocations = @()
$packageInfoPath = Join-Path $TestDrive "azure-test.json"
@{
Name = "azure-test"
Version = "1.0.0"
} | ConvertTo-Json | Set-Content $packageInfoPath
}
It "passes package coordinates, API hash, and repository owner to azsdk" {
$packageInfo = Get-Content $packageInfoPath -Raw | ConvertFrom-Json
$packageInfo | Add-Member -NotePropertyName ApiHash -NotePropertyValue abc123
$packageInfo | ConvertTo-Json | Set-Content $packageInfoPath
& $scriptPath -PackageInfoFiles $packageInfoPath -RepoOwner Contoso
($global:CapturedAzSdkArguments -join "|") | Should Be (@(
"package", "get-approval-status",
"--language", "python",
"--package-name", "azure-test",
"--package-version", "1.0.0",
"--output", "json",
"--api-hash", "abc123",
"--repo-owner", "Contoso"
) -join "|")
}
It "omits the API hash when it is unavailable" {
& $scriptPath -PackageInfoFiles $packageInfoPath
($global:CapturedAzSdkArguments -join "|") | Should Not Match "--api-hash"
($global:CapturedAzSdkArguments -join "|") | Should Not Match "--repo-owner"
}
It "fails without prompting when the azsdk executable is unavailable" {
$missingExecutable = Join-Path $TestDrive "missing-azsdk.exe"
$caughtError = $null
try {
& $scriptPath -PackageInfoFiles $packageInfoPath -AzSdkExePath $missingExecutable
}
catch {
$caughtError = $_
}
$caughtError | Should Not BeNullOrEmpty
$caughtError.Exception.Message | Should Match "azsdk CLI executable was not found"
}
It "fails when the azsdk version is unsupported" {
$global:AzSdkVersion = "0.6.37"
$caughtError = $null
try {
& $scriptPath -PackageInfoFiles $packageInfoPath
}
catch {
$caughtError = $_
}
$caughtError.Exception.Message | Should Match "version 0.6.38 or later is required"
$global:CapturedAzSdkInvocations.Count | Should Be 0
}
It "fails when azsdk returns a nonzero exit code" {
$global:AzSdkExitCode = 1
$global:AzSdkOutput = '{"operation_status":"Failed","response_error":"distinct raw command output"}'
$caughtError = $null
try {
& $scriptPath -PackageInfoFiles $packageInfoPath
}
catch {
$caughtError = $_
}
$caughtError | Should Not BeNullOrEmpty
$caughtError.Exception.Message | Should Match "distinct raw command output"
}
It "logs a reproducible command invocation" {
$packageInfo = Get-Content $packageInfoPath -Raw | ConvertFrom-Json
$packageInfo.Name = "azure test"
$packageInfo | ConvertTo-Json | Set-Content $packageInfoPath
$messages = @(& $scriptPath -PackageInfoFiles $packageInfoPath 6>&1)
($messages -join [Environment]::NewLine) | Should Match 'Command: azsdk package get-approval-status --language python --package-name "azure test" --package-version 1.0.0 --output json'
}
It "shows Review Hub and APIView results before the overall result" {
$global:AzSdkOutput = '{"operation_status":"Succeeded","result":{"isApproved":true,"finalSource":"APIView","reason":"approved","reviewHub":{"isApproved":false,"reason":"repositoryNotSupported","statusCode":200},"apiView":{"isApproved":true,"reason":"approved","statusCode":200,"details":["API review is approved."]}}}'
$messages = @(& $scriptPath -PackageInfoFiles $packageInfoPath 6>&1) |
ForEach-Object { "$_" }
[Array]::IndexOf($messages, "API Review Hub") | Should BeLessThan ([Array]::IndexOf($messages, "APIView"))
[Array]::IndexOf($messages, "APIView") | Should BeLessThan ([Array]::IndexOf($messages, "Overall"))
($messages -join [Environment]::NewLine) | Should Match "Overall\r?\n Status: APPROVED\r?\n Source: APIView\r?\n Reason: approved"
}
It "includes raw output when azsdk returns malformed output" {
$global:AzSdkExitCode = 1
$global:AzSdkOutput = "distinct raw command output"
$caughtError = $null
try {
& $scriptPath -PackageInfoFiles $packageInfoPath
}
catch {
$caughtError = $_
}
$caughtError | Should Not BeNullOrEmpty
$caughtError.Exception.Message | Should Match "distinct raw command output"
}
It "fails when the response is malformed" {
$global:AzSdkOutput = "not json"
{ & $scriptPath -PackageInfoFiles $packageInfoPath } |
Should Throw
}
It "fails when a successful CLI invocation reports an unapproved result" {
$global:AzSdkOutput = '{"operation_status":"Succeeded","result":{"isApproved":false,"finalSource":"none","reason":"pending"}}'
{ & $scriptPath -PackageInfoFiles $packageInfoPath } |
Should Throw
}
It "ignores a failed approval check for an unreleased package" {
$packageInfo = Get-Content $packageInfoPath -Raw | ConvertFrom-Json
$packageInfo | Add-Member -NotePropertyName ReleaseStatus -NotePropertyValue Unreleased
$packageInfo | ConvertTo-Json | Set-Content $packageInfoPath
$global:AzSdkExitCode = 1
$global:AzSdkOutput = '{"operation_status":"Failed","response_error":"Package is not approved."}'
$messages = @(& $scriptPath -PackageInfoFiles $packageInfoPath 6>&1)
($messages -join [Environment]::NewLine) | Should Match "azure-test 1.0.0 is not marked for release. Ignoring approval check failure"
$global:CapturedAzSdkInvocations.Count | Should Be 1
}
It "fails when the response contract is missing the result" {
$global:AzSdkOutput = '{"operation_status":"Succeeded"}'
{ & $scriptPath -PackageInfoFiles $packageInfoPath } |
Should Throw
}
It "fails when the approval decision is not Boolean" {
$global:AzSdkOutput = '{"operation_status":"Succeeded","result":{"isApproved":"false"}}'
{ & $scriptPath -PackageInfoFiles $packageInfoPath } |
Should Throw
}
It "checks every explicitly supplied package-info file" {
$secondPackageInfoPath = Join-Path $TestDrive "azure-test-two.json"
@{
Name = "azure-test-two"
Version = "2.0.0"
ApiHash = "def456"
} | ConvertTo-Json | Set-Content $secondPackageInfoPath
& $scriptPath -PackageInfoFiles @($packageInfoPath, $secondPackageInfoPath)
$global:CapturedAzSdkInvocations.Count | Should Be 2
($global:CapturedAzSdkInvocations[0] -join "|") | Should Match "--package-name\|azure-test\|--package-version\|1.0.0"
($global:CapturedAzSdkInvocations[1] -join "|") | Should Match "--package-name\|azure-test-two\|--package-version\|2.0.0.*--api-hash\|def456"
}
It "continues checking valid packages after invalid package info" {
$invalidPackageInfoPath = Join-Path $TestDrive "invalid.json"
Set-Content $invalidPackageInfoPath "not json"
{ & $scriptPath -PackageInfoFiles @($invalidPackageInfoPath, $packageInfoPath) } |
Should Throw
$global:CapturedAzSdkInvocations.Count | Should Be 1
}
}
@@ -0,0 +1,176 @@
Describe "Mark-PackageReleased.ps1" {
BeforeAll {
$scriptPath = Join-Path (Join-Path $PSScriptRoot "..") "Mark-PackageReleased.ps1"
function global:azsdk {
param (
[Parameter(ValueFromRemainingArguments = $true)]
[object[]] $Arguments
)
if ($Arguments.Count -eq 1 -and $Arguments[0] -eq "--version") {
$global:LASTEXITCODE = 0
return $global:AzSdkVersion
}
$global:CapturedAzSdkArguments = @($Arguments)
$global:CapturedAzSdkInvocations += ,@($Arguments)
$global:LASTEXITCODE = $global:AzSdkExitCode
return $global:AzSdkOutput
}
}
AfterAll {
Remove-Item Function:\azsdk -ErrorAction SilentlyContinue
Remove-Variable AzSdkExitCode, AzSdkOutput, AzSdkVersion, CapturedAzSdkArguments, CapturedAzSdkInvocations, LanguageShort -Scope Global -ErrorAction SilentlyContinue
}
BeforeEach {
$global:LanguageShort = "python"
$global:AzSdkExitCode = 0
$global:AzSdkVersion = "0.6.38"
$global:AzSdkOutput = '{"operation_status":"Succeeded","api_review_hub":{"packageVersionId":"version123","isReleased":true},"api_view":{"revisionId":"revision456","isReleased":true}}'
$global:CapturedAzSdkArguments = @()
$global:CapturedAzSdkInvocations = @()
$packageInfoPath = Join-Path $TestDrive "azure-test.json"
@{
Name = "azure-test"
Version = "1.0.0"
ApiHash = "abc123"
} | ConvertTo-Json | Set-Content $packageInfoPath
}
It "passes package-info release inputs to azsdk" {
& $scriptPath -PackageInfoFiles $packageInfoPath -RepoOwner Azure
($global:CapturedAzSdkArguments -join "|") | Should Be (@(
"package", "mark-released",
"--language", "python",
"--package-name", "azure-test",
"--package-version", "1.0.0",
"--api-hash", "abc123",
"--output", "json",
"--repo-owner", "Azure"
) -join "|")
($global:CapturedAzSdkArguments -join "|") | Should Not Match "--dry-run"
}
It "omits the optional repository owner" {
& $scriptPath -PackageInfoFiles $packageInfoPath
($global:CapturedAzSdkArguments -join "|") | Should Not Match "--repo-owner"
}
It "omits the optional ApiHash" {
$packageInfo = Get-Content $packageInfoPath -Raw | ConvertFrom-Json
$packageInfo.PSObject.Properties.Remove("ApiHash")
$packageInfo | ConvertTo-Json | Set-Content $packageInfoPath
& $scriptPath -PackageInfoFiles $packageInfoPath
$global:CapturedAzSdkInvocations.Count | Should Be 1
($global:CapturedAzSdkArguments -join "|") | Should Not Match "--api-hash"
}
It "fails without prompting when the azsdk executable is unavailable" {
$missingExecutable = Join-Path $TestDrive "missing-azsdk.exe"
$caughtError = $null
try {
& $scriptPath -PackageInfoFiles $packageInfoPath -AzSdkExePath $missingExecutable
}
catch {
$caughtError = $_
}
$caughtError | Should Not BeNullOrEmpty
$caughtError.Exception.Message | Should Match "azsdk CLI executable was not found"
}
It "fails when the azsdk version is unsupported" {
$global:AzSdkVersion = "0.6.37"
$caughtError = $null
try {
& $scriptPath -PackageInfoFiles $packageInfoPath
}
catch {
$caughtError = $_
}
$caughtError.Exception.Message | Should Match "version 0.6.38 or later is required"
$global:CapturedAzSdkInvocations.Count | Should Be 0
}
It "shows both backend results" {
$messages = @(& $scriptPath -PackageInfoFiles $packageInfoPath 6>&1) |
ForEach-Object { "$_" }
[Array]::IndexOf($messages, "API Review Hub") | Should BeLessThan ([Array]::IndexOf($messages, "APIView"))
($messages -join [Environment]::NewLine) | Should Match '"packageVersionId":"version123"'
($messages -join [Environment]::NewLine) | Should Match '"revisionId":"revision456"'
}
It "surfaces partial backend failure details from azsdk" {
$global:AzSdkExitCode = 1
$global:AzSdkOutput = '{"operation_status":"Failed","api_review_hub":{"packageVersionId":"version123"},"api_view":null,"response_errors":["APIView: APIView failed"]}'
$caughtError = $null
try {
& $scriptPath -PackageInfoFiles $packageInfoPath
}
catch {
$caughtError = $_
}
$caughtError | Should Not BeNullOrEmpty
$caughtError.Exception.Message | Should Match "APIView: APIView failed"
}
It "includes raw output when azsdk returns malformed output" {
$global:AzSdkExitCode = 1
$global:AzSdkOutput = "distinct raw command output"
$caughtError = $null
try {
& $scriptPath -PackageInfoFiles $packageInfoPath
}
catch {
$caughtError = $_
}
$caughtError | Should Not BeNullOrEmpty
$caughtError.Exception.Message | Should Match "distinct raw command output"
}
It "accepts a successful response with a missing backend result" {
$global:AzSdkOutput = '{"operation_status":"Succeeded","api_review_hub":{"packageVersionId":"version123"},"api_view":null}'
{ & $scriptPath -PackageInfoFiles $packageInfoPath } | Should Not Throw
}
It "marks every explicitly supplied package-info file" {
$secondPackageInfoPath = Join-Path $TestDrive "azure-test-two.json"
@{
Name = "azure-test-two"
Version = "2.0.0"
ApiHash = "def456"
} | ConvertTo-Json | Set-Content $secondPackageInfoPath
& $scriptPath -PackageInfoFiles @($packageInfoPath, $secondPackageInfoPath)
$global:CapturedAzSdkInvocations.Count | Should Be 2
($global:CapturedAzSdkInvocations[0] -join "|") | Should Match "--package-name\|azure-test\|--package-version\|1.0.0.*--api-hash\|abc123"
($global:CapturedAzSdkInvocations[1] -join "|") | Should Match "--package-name\|azure-test-two\|--package-version\|2.0.0.*--api-hash\|def456"
}
It "continues marking valid packages after invalid package info" {
$invalidPackageInfoPath = Join-Path $TestDrive "invalid.json"
Set-Content $invalidPackageInfoPath "not json"
{ & $scriptPath -PackageInfoFiles @($invalidPackageInfoPath, $packageInfoPath) } |
Should Throw
$global:CapturedAzSdkInvocations.Count | Should Be 1
}
}