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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
parameters:
- name: DependsOn
type: object
default:
- Signing
- name: Artifacts
type: object
default: []
- name: Condition
type: string
default: succeeded()

stages:
# Post-merge auto-release preparation, shared across language repos (this file lives in the synced
# eng/common tree). Runs after the Signing stage: it resolves the merged PR for Build.SourceVersion,
# requires the auto-release label, maps the PR's changed files to releasable packages via the language
# repo's own package detection (Get-PrPkgProperties -> Get-AllPackageInfoFromRepo), and emits the
# auto-release output variables consumed by each language's release stage.
#
# The stage/job/step names below (AutoReleasePrepare / ResolveAutoReleasePackages / resolve) are the
# fixed cross-language output contract. Downstream release stages read outputs as:
# condition: dependencies.AutoReleasePrepare.outputs['ResolveAutoReleasePackages.resolve.<var>']
# variables: stageDependencies.AutoReleasePrepare.ResolveAutoReleasePackages.outputs['resolve.<var>']
# Emitted variables: AutoReleasePrNumber, AutoReleaseLabelPresent, HasAutoReleaseArtifacts,
# AutoReleaseArtifactsJson, and ReleaseArtifact_<safeName> per declared artifact.
- stage: AutoReleasePrepare
displayName: Auto-release prepare
dependsOn: ${{ parameters.DependsOn }}
condition: ${{ parameters.Condition }}
variables:
- template: /eng/pipelines/templates/variables/globals.yml
- template: /eng/pipelines/templates/variables/image.yml
jobs:
- job: ResolveAutoReleasePackages
displayName: Resolve releasable packages from merged PR
pool:
name: $(LINUXPOOL)
image: $(LINUXVMIMAGE)
os: linux
steps:
# Package detection reads package properties from source, so a checkout is required.
- checkout: self
fetchDepth: 1

- template: /eng/common/pipelines/templates/steps/login-to-github.yml

- task: PowerShell@2
name: resolve
displayName: Determine releasable packages
env:
# convertToJson emits multi-line JSON, so pass it via env instead of the command line.
AUTORELEASE_ARTIFACTS: ${{ convertToJson(parameters.Artifacts) }}
# Map the secret token to env so it is not written to the task command line.
GH_TOKEN: $(GH_TOKEN)
inputs:
pwsh: true
filePath: $(System.DefaultWorkingDirectory)/eng/common/scripts/Resolve-AutoReleasePackages.ps1
arguments: >
-CommitSha "$(Build.SourceVersion)"
-RepoId "$(Build.Repository.Name)"
142 changes: 142 additions & 0 deletions eng/common/scripts/AutoRelease-Operations.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
# Shared auto-release operations used by language repos to resolve the pull request and
# changed-file set that drive post-merge auto-release. Generic GitHub API calls live in
# Invoke-GitHubAPI.ps1; this file holds the auto-release policy and diff-shaping logic.

. "${PSScriptRoot}\logging.ps1"
. "${PSScriptRoot}\Invoke-GitHubAPI.ps1"

# Resolves the auto-release pull request for a commit SHA.
#
# Applies the shared auto-release selection policy:
# 1. Look up the pull requests associated with the commit.
# 2. Keep only pull requests merged into the target branch.
# 3. Select the most recently merged one.
# 4. Re-fetch that pull request by number to read its authoritative merge state and labels.
# 5. Require it to be merged into the target branch and to carry the auto-release label.
#
# Returns a result object:
# PullRequest : the selected PR object, or $null
# PullRequestNumber : the selected PR number, or $null
# IsEligible : $true only when a merged, labeled PR was found
# SkipReason : a human readable reason when IsEligible is $false
function Get-GitHubAutoReleasePullRequestForCommit {
param (
$RepoOwner,
$RepoName,
$RepoId = "$RepoOwner/$RepoName",
[Parameter(Mandatory = $true)]
[ValidateNotNullOrEmpty()]
$CommitSha,
$TargetBranch = "main",
$RequiredLabel = "auto-release",
[ValidateNotNullOrEmpty()]
[Parameter(Mandatory = $true)]
$AuthToken
)

$result = [PSCustomObject]@{
PullRequest = $null
PullRequestNumber = $null
IsEligible = $false
SkipReason = ""
}

$associatedPullRequests = @(Get-GitHubPullRequestsForCommit -RepoId $RepoId -CommitSha $CommitSha -AuthToken $AuthToken)

$mergedToTarget = @(
$associatedPullRequests |
Where-Object { $_.merged_at -and $_.base.ref -eq $TargetBranch }
)

if ($mergedToTarget.Count -eq 0) {
$result.SkipReason = "No merged pull request targeting '$TargetBranch' was associated with commit '$CommitSha'."
return $result
}

$selectedPullRequest = $mergedToTarget |
Sort-Object { [datetime]$_.merged_at } -Descending |
Select-Object -First 1

$result.PullRequestNumber = $selectedPullRequest.number

# Re-fetch the pull request by number to read its authoritative state. The commit -> pulls payload is
# a secondary representation whose labels and merge state can lag the canonical pull request, so
# eligibility (merge target + required label) is decided against this authoritative payload.
$pullRequest = Get-GitHubPullRequest -RepoId $RepoId -PullRequestNumber $selectedPullRequest.number -AuthToken $AuthToken
$result.PullRequest = $pullRequest

if (-not $pullRequest.merged_at -or $pullRequest.base.ref -ne $TargetBranch) {
$result.SkipReason = "Pull request #$($pullRequest.number) is not merged into '$TargetBranch'."
return $result
}

$labels = @($pullRequest.labels | ForEach-Object { $_.name })
if ($RequiredLabel -notin $labels) {
$result.SkipReason = "Pull request #$($pullRequest.number) does not have the required label '$RequiredLabel'."
return $result
}

$result.IsEligible = $true
return $result
}

# Converts GitHub pull request file entries into the Azure SDK PR diff object shape
# consumed by package-detection tooling (compatible with Generate-PR-Diff.ps1 output).
#
# Parameters:
# PullRequestNumber : the PR number recorded in the diff object.
# PullRequestFiles : the file entries returned by Get-GitHubPullRequestFiles.
# ExcludePaths : optional paths to record on the diff object.
#
# Returns a PSCustomObject with ChangedFiles, ChangedServices, ExcludePaths, DeletedFiles, PRNumber.
function New-GitHubPullRequestDiffObject {
param (
[Parameter(Mandatory = $true)]
$PullRequestNumber,
[Parameter(Mandatory = $true)]
[AllowEmptyCollection()]
[array] $PullRequestFiles,
[AllowEmptyCollection()]
[array] $ExcludePaths = @()
)

$changedFiles = @()
$deletedFiles = @()

foreach ($file in $PullRequestFiles) {
$filename = "$($file.filename)" -replace '\\', '/'

if ($file.status -eq 'removed') {
$deletedFiles += $filename
}
else {
$changedFiles += $filename
}

# For renames, include the previous path as deleted so package detection sees both sides of the move.
if ($file.status -eq 'renamed' -and $file.previous_filename) {
$deletedFiles += ("$($file.previous_filename)" -replace '\\', '/')
}
}

$changedFiles = @($changedFiles | Where-Object { $_ } | Sort-Object -Unique)
$deletedFiles = @($deletedFiles | Where-Object { $_ } | Sort-Object -Unique)

$changedServices = @(
$changedFiles + $deletedFiles |
ForEach-Object { if ($_ -match "^sdk/([^/]+)/") { $Matches[1] } } |
Sort-Object -Unique
)

if (-not $ExcludePaths) {
$ExcludePaths = @()
}

return [PSCustomObject]@{
ChangedFiles = $changedFiles
ChangedServices = $changedServices
ExcludePaths = @($ExcludePaths)
DeletedFiles = $deletedFiles
PRNumber = "$PullRequestNumber"
}
}
60 changes: 60 additions & 0 deletions eng/common/scripts/Invoke-GitHubAPI.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,66 @@ function Get-GitHubPullRequest {
-MaximumRetryCount 3
}

# Returns the pull requests associated with a commit SHA.
# See https://docs.github.com/rest/commits/commits#list-pull-requests-associated-with-a-commit
function Get-GitHubPullRequestsForCommit {
param (
$RepoOwner,
$RepoName,
$RepoId = "$RepoOwner/$RepoName",
[Parameter(Mandatory = $true)]
[ValidateNotNullOrEmpty()]
$CommitSha,
[ValidateNotNullOrEmpty()]
[Parameter(Mandatory = $true)]
$AuthToken
)
# A commit is associated with ~1 pull request, so a single request is sufficient.
$uri = "$GithubAPIBaseURI/$RepoId/commits/$CommitSha/pulls"

return Invoke-RestMethod `
-Method GET `
-Uri $uri `
-Headers (Get-GitHubApiHeaders -token $AuthToken) `
-MaximumRetryCount 3
}

# Returns the list of files changed in a pull request, following pagination.
# See https://docs.github.com/rest/pulls/pulls#list-pull-requests-files
function Get-GitHubPullRequestFiles {
param (
$RepoOwner,
$RepoName,
$RepoId = "$RepoOwner/$RepoName",
[Parameter(Mandatory = $true)]
[ValidateNotNullOrEmpty()]
$PullRequestNumber,
[ValidateNotNullOrEmpty()]
[Parameter(Mandatory = $true)]
$AuthToken
)

$headers = Get-GitHubApiHeaders -token $AuthToken
$pageSize = 100
$page = 1
$files = @()

do {
$uri = "$GithubAPIBaseURI/$RepoId/pulls/$PullRequestNumber/files?per_page=$pageSize&page=$page"
$response = Invoke-RestMethod `
-Method GET `
-Uri $uri `
-Headers $headers `
-MaximumRetryCount 3

$response = @($response)
if ($response.Count -gt 0) { $files += $response }
$page++
} while ($response.Count -eq $pageSize)

return $files
}

function New-GitHubPullRequest {
param (
$RepoOwner,
Expand Down
Loading
Loading