diff --git a/eng/docker-tools/DEV-GUIDE.md b/eng/docker-tools/DEV-GUIDE.md index f834566d..28ddc471 100644 --- a/eng/docker-tools/DEV-GUIDE.md +++ b/eng/docker-tools/DEV-GUIDE.md @@ -427,6 +427,8 @@ When you queue a new run, you can override these as runtime parameters: This avoids the multi-hour rebuild cycle when you just need to retry a failed operation. +When signing is enabled, use `"publish"` by itself only if the images from `sourceBuildPipelineRunId` were already signed and the current run is not building new images. Use `"sign,publish"` when the current run still needs to sign them before publishing. + --- ## Troubleshooting diff --git a/eng/docker-tools/skill-helpers/AzureDevOps.ps1 b/eng/docker-tools/skill-helpers/AzureDevOps.ps1 index ad699067..fd30c92e 100644 --- a/eng/docker-tools/skill-helpers/AzureDevOps.ps1 +++ b/eng/docker-tools/skill-helpers/AzureDevOps.ps1 @@ -41,6 +41,8 @@ function Invoke-AzDORestMethod { Request body as a hashtable. Automatically converted to JSON. .PARAMETER ApiVersion API version. Defaults to 7.1. + .PARAMETER QueryParams + Optional hashtable of additional query string parameters. #> [CmdletBinding()] param( @@ -49,7 +51,8 @@ function Invoke-AzDORestMethod { [Parameter(Mandatory)][string] $Endpoint, [string] $Method = "GET", [hashtable] $Body, - [string] $ApiVersion = "7.1" + [string] $ApiVersion = "7.1", + [hashtable] $QueryParams ) $token = Get-AzDOAccessToken @@ -58,7 +61,15 @@ function Invoke-AzDORestMethod { "Content-Type" = "application/json" } - $uri = "https://dev.azure.com/$Organization/$Project/_apis/$($Endpoint)?api-version=$ApiVersion" + $query = "api-version=$ApiVersion" + if ($QueryParams) { + foreach ($key in $QueryParams.Keys) { + $value = [System.Uri]::EscapeDataString([string]$QueryParams[$key]) + $query += "&$key=$value" + } + } + + $uri = "https://dev.azure.com/$Organization/$Project/_apis/$($Endpoint)?$query" $params = @{ Uri = $uri diff --git a/eng/docker-tools/skill-helpers/Get-FailingPipelines.ps1 b/eng/docker-tools/skill-helpers/Get-FailingPipelines.ps1 new file mode 100644 index 00000000..cfc3750c --- /dev/null +++ b/eng/docker-tools/skill-helpers/Get-FailingPipelines.ps1 @@ -0,0 +1,63 @@ +#!/usr/bin/env pwsh +# Lists pipeline definitions in a folder whose most recent completed build did not succeed. +# Usage: +# ./Get-FailingPipelines.ps1 -Organization dnceng -Project internal -Folder dotnet/docker-tools + +[CmdletBinding()] +param( + [Parameter(Mandatory)][string] $Organization, + [Parameter(Mandatory)][string] $Project, + [Parameter(Mandatory)][string] $Folder, + [switch] $IncludeWarnings +) + +$ErrorActionPreference = "Stop" + +. "$PSScriptRoot/AzureDevOps.ps1" + +# Normalize folder: accept "dotnet/docker-tools" or "\dotnet\docker-tools". +$normalizedFolder = "\" + ($Folder.Trim('\', '/') -replace '/', '\') + +$failingResults = @("failed", "canceled") +if ($IncludeWarnings) { + $failingResults += "partiallySucceeded" +} + +$definitions = Invoke-AzDORestMethod ` + -Organization $Organization ` + -Project $Project ` + -Endpoint "build/definitions" ` + -QueryParams @{ + path = $normalizedFolder + includeLatestBuilds = "true" + } + +$failing = @() +foreach ($def in $definitions.value) { + $latest = $def.latestCompletedBuild + if (-not $latest) { continue } + if ($failingResults -notcontains $latest.result) { continue } + + $failing += [pscustomobject]@{ + Definition = $def.name + Result = $latest.result + BuildId = $latest.id + BuildNumber = $latest.buildNumber + Branch = $latest.sourceBranch + FinishTime = $latest.finishTime + Url = "https://dev.azure.com/$Organization/$Project/_build/results?buildId=$($latest.id)" + } +} + +Write-Host "## Failing pipelines in $normalizedFolder" +Write-Host "" +Write-Host "Found $($failing.Count) of $($definitions.value.Count) pipeline(s) with a failing latest run." +Write-Host "" + +if ($failing.Count -gt 0) { + Write-Host "Pipeline | Result | Build | Branch | Finished | Link" + Write-Host "--- | --- | --- | --- | --- | ---" + foreach ($item in $failing | Sort-Object Definition) { + Write-Host "$($item.Definition) | $($item.Result) | $($item.BuildId) | $($item.Branch) | $($item.FinishTime) | $($item.Url)" + } +} diff --git a/eng/docker-tools/skill-helpers/Get-RecentBuilds.ps1 b/eng/docker-tools/skill-helpers/Get-RecentBuilds.ps1 new file mode 100644 index 00000000..24d14ccb --- /dev/null +++ b/eng/docker-tools/skill-helpers/Get-RecentBuilds.ps1 @@ -0,0 +1,58 @@ +#!/usr/bin/env pwsh +# Lists all build runs in the last N hours for pipelines under a given folder. +# Usage: +# ./Get-RecentBuilds.ps1 -Organization dnceng -Project internal -Folder dotnet/docker-tools +# ./Get-RecentBuilds.ps1 -Organization dnceng -Project internal -Folder dotnet/docker-tools -Hours 48 + +[CmdletBinding()] +param( + [Parameter(Mandatory)][string] $Organization, + [Parameter(Mandatory)][string] $Project, + [Parameter(Mandatory)][string] $Folder, + [int] $Hours = 24 +) + +$ErrorActionPreference = "Stop" + +. "$PSScriptRoot/AzureDevOps.ps1" + +$normalizedFolder = "\" + ($Folder.Trim('\', '/') -replace '/', '\') +$minTime = [DateTime]::UtcNow.AddHours(-$Hours).ToString("o") + +$definitions = Invoke-AzDORestMethod ` + -Organization $Organization ` + -Project $Project ` + -Endpoint "build/definitions" ` + -QueryParams @{ path = $normalizedFolder } + +if (-not $definitions.value -or $definitions.value.Count -eq 0) { + Write-Host "## No pipelines found in $normalizedFolder" + return +} + +$definitionIds = ($definitions.value | ForEach-Object { $_.id }) -join "," + +$builds = Invoke-AzDORestMethod ` + -Organization $Organization ` + -Project $Project ` + -Endpoint "build/builds" ` + -QueryParams @{ + definitions = $definitionIds + minTime = $minTime + queryOrder = "finishTimeDescending" + } + +Write-Host "## Builds in $normalizedFolder (last $Hours hours)" +Write-Host "" +Write-Host "Found $($builds.value.Count) build(s) across $($definitions.value.Count) pipeline(s)." +Write-Host "" + +if ($builds.value.Count -gt 0) { + Write-Host "Pipeline | State | Build | Branch | Finished | Link" + Write-Host "--- | --- | --- | --- | --- | ---" + foreach ($build in $builds.value) { + $state = if ($build.status -eq "completed") { $build.result } else { $build.status } + $url = "https://dev.azure.com/$Organization/$Project/_build/results?buildId=$($build.id)" + Write-Host "$($build.definition.name) | $state | $($build.id) | $($build.sourceBranch) | $($build.finishTime) | $url" + } +} diff --git a/eng/docker-tools/skill-helpers/Show-BuildTimeline.ps1 b/eng/docker-tools/skill-helpers/Show-BuildTimeline.ps1 index a51b2287..7de331e5 100644 --- a/eng/docker-tools/skill-helpers/Show-BuildTimeline.ps1 +++ b/eng/docker-tools/skill-helpers/Show-BuildTimeline.ps1 @@ -21,7 +21,7 @@ $build = Invoke-AzDORestMethod ` -Project $Project ` -Endpoint "build/builds/$BuildId" -Write-Host "# Build $BuildId - $($build.definition.name)" +Write-Host "## Build $BuildId - $($build.definition.name)" Write-Host "" Write-Host "- Status: $($build.status) $(if ($build.result) { "($($build.result))" })" Write-Host "- Branch: $($build.sourceBranch)" @@ -71,7 +71,7 @@ function Write-TimelineNode([string] $nodeId, [int] $depth) { } } -Write-Host "## Build Timeline" +Write-Host "### Build Timeline" Write-Host "" Write-TimelineNode "" 0 Write-Host "" diff --git a/eng/docker-tools/skill-helpers/Show-PullRequestBuilds.ps1 b/eng/docker-tools/skill-helpers/Show-PullRequestBuilds.ps1 new file mode 100644 index 00000000..d49224e0 --- /dev/null +++ b/eng/docker-tools/skill-helpers/Show-PullRequestBuilds.ps1 @@ -0,0 +1,99 @@ +#!/usr/bin/env pwsh +# Shows all PR checks as a summary table, then expands AzDO build timelines for any +# checks that point at Azure Pipelines (https://dev.azure.com/...). +# Requires `gh` CLI authenticated against the target repo. +# +# Usage: +# ./Show-PullRequestBuilds.ps1 -PullRequest 2100 +# ./Show-PullRequestBuilds.ps1 -PullRequest 2100 -Repo dotnet/docker-tools +# ./Show-PullRequestBuilds.ps1 -PullRequest 2100 -ShowAllTasks + +[CmdletBinding()] +param( + [Parameter(Mandatory)][int] $PullRequest, + [string] $Repo, + [switch] $ShowAllTasks +) + +$ErrorActionPreference = "Stop" + +$ghArgs = @("pr", "view", $PullRequest, "--json", "statusCheckRollup") +if ($Repo) { $ghArgs += @("--repo", $Repo) } + +$checksJson = & gh @ghArgs 2>&1 +if ($LASTEXITCODE -ne 0) { + throw "gh pr view failed: $checksJson" +} + +$checks = ($checksJson | ConvertFrom-Json).statusCheckRollup + +# statusCheckRollup mixes two shapes: +# CheckRun: { name, status, conclusion, detailsUrl, workflowName } +# StatusContext: { context, state, targetUrl, description } +# Normalize them. +$normalized = foreach ($check in $checks) { + if ($check.PSObject.Properties.Name -contains "context") { + [pscustomobject]@{ + Name = $check.context + State = $check.state + Url = $check.targetUrl + } + } + else { + $state = if ($check.conclusion) { $check.conclusion } else { $check.status } + [pscustomobject]@{ + Name = $check.name + State = $state + Url = $check.detailsUrl + } + } +} + +# AzDO build results URLs look like: +# https://dev.azure.com///_build/results?buildId=... +$pattern = '^https?://dev\.azure\.com/(?[^/]+)/(?[^/]+)/_build/results\?.*buildId=(?\d+)' + +$builds = @() +foreach ($check in $normalized) { + if (-not $check.Url) { continue } + $match = [regex]::Match($check.Url, $pattern) + if (-not $match.Success) { continue } + + $builds += [pscustomobject]@{ + Org = $match.Groups["org"].Value + Project = $match.Groups["project"].Value + BuildId = [int]$match.Groups["buildId"].Value + } +} + +# Deduplicate by buildId (a single build can produce multiple check-run rows). +$builds = $builds | Sort-Object BuildId -Unique + +$title = if ($Repo) { "$Repo#$PullRequest" } else { "PR #$PullRequest" } +Write-Host "## Checks for $title" +Write-Host "" +Write-Host "$($normalized.Count) check(s); $($builds.Count) Azure Pipelines build(s)." +Write-Host "" + +if ($normalized.Count -gt 0) { + Write-Host "Check | State | URL" + Write-Host "--- | --- | ---" + foreach ($check in $normalized | Sort-Object Name) { + Write-Host "$($check.Name) | $($check.State) | $($check.Url)" + } + Write-Host "" +} + +if ($builds.Count -eq 0) { return } + +$timelineScript = "$PSScriptRoot/Show-BuildTimeline.ps1" + +foreach ($build in $builds) { + Write-Host "---" + Write-Host "" + & $timelineScript ` + -Organization $build.Org ` + -Project $build.Project ` + -BuildId $build.BuildId ` + -ShowAllTasks:$ShowAllTasks +} diff --git a/eng/docker-tools/skill-helpers/Show-PullRequestComments.ps1 b/eng/docker-tools/skill-helpers/Show-PullRequestComments.ps1 new file mode 100644 index 00000000..acb1d59b --- /dev/null +++ b/eng/docker-tools/skill-helpers/Show-PullRequestComments.ps1 @@ -0,0 +1,117 @@ +#!/usr/bin/env pwsh +# Shows a focused summary of a pull request: metadata, reviews, issue-level comments, +# and inline review comments (which `gh pr view --json` does not expose). +# Requires `gh` CLI authenticated against the target repo. +# +# Usage: +# ./Show-PullRequestComments.ps1 2100 +# ./Show-PullRequestComments.ps1 2100 -Repo dotnet/docker-tools + +[CmdletBinding()] +param( + [Parameter(Mandatory)][int] $PullRequest, + [string] $Repo +) + +$ErrorActionPreference = "Stop" +$PSNativeCommandUseErrorActionPreference = $true + +function Write-BlockComment { + param([string] $Text) + if (-not $Text) { return } + $Text.TrimEnd() -split "`n" | ForEach-Object { Write-Host "> $_" } + Write-Host "" +} + +# Fetch PR overview. +$viewArgs = @( + "pr", "view", $PullRequest, + "--json", "number,title,state,author,baseRefName,headRefName,isDraft,url,additions,deletions,changedFiles,reviewDecision,labels,reviews,comments" +) +if ($Repo) { $viewArgs += @("--repo", $Repo) } + +$prJson = & gh @viewArgs +$pr = $prJson | ConvertFrom-Json + +# Fetch inline review comments via REST API. `gh pr view --json` exposes review bodies +# but drops the inline diff comments attached to specific files/lines. +$apiPath = if ($Repo) { + "repos/$Repo/pulls/$PullRequest/comments" +} else { + "repos/{owner}/{repo}/pulls/$PullRequest/comments" +} + +$inlineJson = & gh api --paginate $apiPath +$inline = $inlineJson | ConvertFrom-Json + +# Render. +$title = if ($Repo) { "$Repo#$PullRequest" } else { "PR #$PullRequest" } +Write-Host "## $title - $($pr.title)" +Write-Host "" +Write-Host "- State: $($pr.state)$(if ($pr.isDraft) { ' (draft)' })" +Write-Host "- Author: $($pr.author.login)" +Write-Host "- Branch: $($pr.headRefName) -> $($pr.baseRefName)" +Write-Host "- Changes: +$($pr.additions)/-$($pr.deletions) ($($pr.changedFiles) files)" +Write-Host "- Review decision: $($pr.reviewDecision)" +if ($pr.labels) { + Write-Host "- Labels: $(($pr.labels | ForEach-Object { $_.name }) -join ', ')" +} +Write-Host "- URL: $($pr.url)" +Write-Host "" + +# Conversation: top-level issue comments and review submissions, merged in +# chronological order. +$conversation = @() +foreach ($comment in $pr.comments) { + $conversation += [pscustomobject]@{ + Timestamp = [datetime]$comment.createdAt + Header = "**$($comment.author.login)** commented at $($comment.createdAt):" + Body = $comment.body + } +} +foreach ($review in $pr.reviews) { + $header = if ($review.state -eq "COMMENTED") { + "**$($review.author.login)** left a comment at $($review.submittedAt):" + } else { + "**$($review.author.login)** reviewed ($($review.state)) at $($review.submittedAt):" + } + $conversation += [pscustomobject]@{ + Timestamp = [datetime]$review.submittedAt + Header = $header + Body = $review.body + } +} +$conversation = $conversation | Sort-Object Timestamp + +Write-Host "### Conversation ($($conversation.Count))" +Write-Host "" +if ($conversation.Count -eq 0) { + Write-Host "_None_" +} else { + foreach ($entry in $conversation) { + Write-Host $entry.Header + Write-BlockComment $entry.Body + } +} +Write-Host "" + +# Inline review comments grouped by file and line. +Write-Host "### Code review comments ($($inline.Count))" +Write-Host "" +if ($inline.Count -eq 0) { + Write-Host "_None_" +} else { + $grouped = $inline | + Group-Object -Property { "$($_.path):$(if ($_.line) { $_.line } else { $_.original_line })" } | + Sort-Object Name + foreach ($group in $grouped) { + $first = $group.Group[0] + $line = if ($first.line) { $first.line } else { $first.original_line } + Write-Host "#### $($first.path) (line $line)" + Write-Host "" + foreach ($comment in $group.Group | Sort-Object created_at) { + Write-Host "**$($comment.user.login)** commented at $($comment.created_at):" + Write-BlockComment $comment.body + } + } +} diff --git a/eng/docker-tools/templates/stages/publish.yml b/eng/docker-tools/templates/stages/publish.yml index 7195aaa8..2c17283e 100644 --- a/eng/docker-tools/templates/stages/publish.yml +++ b/eng/docker-tools/templates/stages/publish.yml @@ -47,7 +47,7 @@ stages: # Run when all of the following are true: # 1. The pipeline has not been canceled. # 2. The stages variable includes 'publish'. - # 3. Either signing is not enabled, or the Sign stage succeeded. + # 3. Either signing is not enabled, this run is reusing previously signed images, or the Sign stage succeeded. # 4. Either the stages variable does not include 'build', or Post_Build succeeded. # 5. Either the stages variable does not include 'test', or Test succeeded/was skipped. condition: " @@ -56,6 +56,10 @@ stages: contains(variables['stages'], 'publish'), or( ne(lower('${{ parameters.publishConfig.Signing.Enabled }}'), 'true'), + and( + not(contains(variables['stages'], 'build')), + not(contains(variables['stages'], 'sign')) + ), in(dependencies.Sign.result, 'Succeeded', 'SucceededWithIssues') ), or( diff --git a/eng/docker-tools/templates/variables/docker-images.yml b/eng/docker-tools/templates/variables/docker-images.yml index 35d030f0..8f7679aa 100644 --- a/eng/docker-tools/templates/variables/docker-images.yml +++ b/eng/docker-tools/templates/variables/docker-images.yml @@ -1,5 +1,5 @@ variables: - imageNames.imageBuilderName: mcr.microsoft.com/dotnet-buildtools/image-builder:2958537 + imageNames.imageBuilderName: mcr.microsoft.com/dotnet-buildtools/image-builder:2980918 imageNames.imageBuilder: $(imageNames.imageBuilderName) imageNames.imageBuilder.withrepo: imagebuilder-withrepo:$(Build.BuildId)-$(System.JobId) imageNames.testRunner: mcr.microsoft.com/dotnet-buildtools/prereqs:azurelinux3.0-docker-testrunner