mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-08-29 10:09:43 +02:00
## Summary of the Pull Request Adds a `Prepare Preview Release` custom agent that autonomously turns a successful PowerToys Azure DevOps release-candidate build into a complete GitHub draft prerelease for final human review. The implementation extends the existing `release-note-generation` skill instead of duplicating it. It adds exact-build metadata resolution, published-release baseline selection, semantic PR deltas across `main` and `stable`, release asset validation, idempotent draft-only release updates, and final draft verification. ## PR Checklist - [x] **Communication:** The autonomous preview-release design was reviewed and approved before implementation - [x] **Tests:** Added/updated and all pass - [x] **Localization:** N/A; no end-user-facing strings were added - [x] **Dev docs:** Added preview scenario, delta, draft safety, and reporting references ## Detailed Description of the Pull Request / Additional comments - Adds `.github/agents/prepare-preview-release.agent.md` with a no-mid-run-decision workflow and a strict prohibition on publishing releases. - Extends `.github/skills/release-note-generation/SKILL.md` with stable/preview scenario routing while preserving the existing stable-release workflow. - Adds canonical scripts under `.github/skills/release-note-generation/scripts/` to: - Resolve and validate ADO build metadata. - Select the latest published stable or preview baseline before build queue time. - Calculate same-lineage or branch-transition PR deltas using PR numbers, cherry-pick provenance, and patch-ID equivalence. - Collect normalized PR metadata and create `release-manifest.json`. - Download and validate installers, symbols, and GPO assets, including hashes, signatures, and ZIP contents. - Create or update draft prereleases while preserving human text outside managed markers. - Verify draft flags, immutable target commit, body markers, and uploaded assets. - Updates `.pipelines/resolveBuildMetadata.ps1` and `.pipelines/v2/release.yml` with explicit `auto`, `preview-release`, and `stable-release` intent handling so preview candidates can be built from either `main` or `stable`. - Adds `.pipelines/writeReleaseMetadata.ps1` so each signed build artifact records its resolved version, channel, intent, source branch, and immutable source commit. - Keeps release publication outside the agent: the automation can only create or update a draft prerelease. ## Validation Steps Performed - `Invoke-Pester` for: - `.pipelines/tests/resolveBuildMetadata.Tests.ps1` - `.pipelines/tests/writeReleaseMetadata.Tests.ps1` - `.github/skills/release-note-generation/tests/preview-release.Tests.ps1` - 37 tests passed, covering stable-branch preview intent, metadata contracts, baseline selection, same-lineage and branch-transition deltas, patch-ID equivalence, managed-body preservation, and published-release refusal. - Parsed all added or modified PowerShell scripts with the PowerShell AST parser. - Parsed the modified pipeline YAML files with `ConvertFrom-Yaml`. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8e04a72e-3b0f-4ac4-8156-d04ea9b8bb85 Copilot-Session: e9f79ac2-9a7b-4083-834c-0d87e8c83bfd Copilot-Session: 1ecea747-b313-49a1-9969-543c01ba1be8
388 lines
13 KiB
PowerShell
388 lines
13 KiB
PowerShell
[CmdletBinding()]
|
|
param(
|
|
[AllowEmptyString()]
|
|
[string]$VersionOverride = "",
|
|
|
|
[ValidateSet("auto", "preview-release", "stable-release")]
|
|
[string]$ReleaseIntent = "auto",
|
|
|
|
[string]$SourceBranch = $env:BUILD_SOURCEBRANCH,
|
|
|
|
[string]$BuildReason = $env:BUILD_REASON,
|
|
|
|
[string]$BuildNumber = $env:BUILD_BUILDNUMBER,
|
|
|
|
[AllowEmptyString()]
|
|
[string]$BuildDate = "",
|
|
|
|
[AllowEmptyString()]
|
|
[string]$DailyVersionSequence = "",
|
|
|
|
[string]$VersionPropsPath = (Join-Path $PSScriptRoot "..\src\Version.props")
|
|
)
|
|
|
|
$ErrorActionPreference = "Stop"
|
|
|
|
$VersionOverride = $VersionOverride.Trim()
|
|
if ($VersionOverride.Equals("auto", [StringComparison]::OrdinalIgnoreCase)) {
|
|
$VersionOverride = ""
|
|
}
|
|
|
|
function Get-BuildStamp {
|
|
param([string]$PipelineBuildNumber)
|
|
|
|
if ([string]::IsNullOrWhiteSpace($PipelineBuildNumber)) {
|
|
$now = Get-Date
|
|
return [pscustomobject]@{
|
|
Date = $now.Date
|
|
Revision = 1
|
|
}
|
|
}
|
|
|
|
if ($PipelineBuildNumber -notmatch "_(?<yearMonth>\d{4})\.(?<day>\d{2})(?<revision>\d{3})(?:-.+)?$") {
|
|
throw "Build number '$PipelineBuildNumber' does not end with the expected _YYMM.DDNNN pattern"
|
|
}
|
|
|
|
try {
|
|
$date = [datetime]::ParseExact(
|
|
"20$($matches["yearMonth"])$($matches["day"])",
|
|
"yyyyMMdd",
|
|
[Globalization.CultureInfo]::InvariantCulture)
|
|
}
|
|
catch {
|
|
throw "Build number '$PipelineBuildNumber' contains an invalid date"
|
|
}
|
|
|
|
$revision = [int]::Parse($matches["revision"])
|
|
if ($revision -lt 1 -or $revision -gt 99) {
|
|
throw "Build number '$PipelineBuildNumber' has daily revision '$revision'; canonical versions support revisions 001 through 099"
|
|
}
|
|
|
|
return [pscustomobject]@{
|
|
Date = $date
|
|
Revision = $revision
|
|
}
|
|
}
|
|
|
|
function Test-VersionParts {
|
|
param([Parameter(Mandatory)][string[]]$Parts)
|
|
|
|
foreach ($part in $Parts) {
|
|
$value = [int]::Parse($part)
|
|
if ($value -lt 0 -or $value -gt [UInt16]::MaxValue) {
|
|
throw "Version component '$value' is outside the supported Windows version range 0-65535"
|
|
}
|
|
}
|
|
}
|
|
|
|
function Get-VersionDate {
|
|
param(
|
|
[AllowEmptyString()][string]$DateOverride,
|
|
[Parameter(Mandatory)]$BuildStamp
|
|
)
|
|
|
|
if ([string]::IsNullOrWhiteSpace($DateOverride)) {
|
|
return $BuildStamp.Date
|
|
}
|
|
|
|
try {
|
|
return [datetime]::ParseExact(
|
|
$DateOverride,
|
|
"yyyyMMdd",
|
|
[Globalization.CultureInfo]::InvariantCulture)
|
|
}
|
|
catch {
|
|
throw "Build date '$DateOverride' must use the yyyyMMdd format"
|
|
}
|
|
}
|
|
|
|
function Get-ReleaseTrainMetadata {
|
|
param([Parameter(Mandatory)][string]$Path)
|
|
|
|
[xml]$versionProps = Get-Content -LiteralPath $Path
|
|
$releaseTrain = [string]$versionProps.Project.PropertyGroup.ReleaseTrainVersion
|
|
if ($releaseTrain -notmatch "^(?<major>\d+)\.(?<minor>\d+)$") {
|
|
throw "ReleaseTrainVersion in '$Path' must use the major.minor format"
|
|
}
|
|
|
|
Test-VersionParts -Parts @($matches["major"], $matches["minor"])
|
|
if ([int]::Parse($matches["major"]) -gt 255 -or [int]::Parse($matches["minor"]) -gt 255) {
|
|
throw "ReleaseTrainVersion in '$Path' must keep major and minor within the MSI-supported range 0-255"
|
|
}
|
|
|
|
$epochText = [string]$versionProps.Project.PropertyGroup.ReleaseTrainEpoch
|
|
try {
|
|
$epoch = [datetime]::ParseExact(
|
|
$epochText,
|
|
"yyyy-MM-dd",
|
|
[Globalization.CultureInfo]::InvariantCulture)
|
|
}
|
|
catch {
|
|
throw "ReleaseTrainEpoch in '$Path' must use the yyyy-MM-dd format"
|
|
}
|
|
|
|
if ($epoch.Month -ne 1 -or $epoch.Day -ne 1) {
|
|
throw "ReleaseTrainEpoch in '$Path' must be January 1 of the active epoch year"
|
|
}
|
|
|
|
return [pscustomobject]@{
|
|
Version = $releaseTrain
|
|
Epoch = $epoch
|
|
}
|
|
}
|
|
|
|
function Get-ReleaseVersion {
|
|
param(
|
|
[Parameter(Mandatory)][string]$ReleaseTrain,
|
|
[Parameter(Mandatory)][datetime]$Epoch,
|
|
[Parameter(Mandatory)]$BuildStamp,
|
|
[Parameter(Mandatory)][int]$DailySequence
|
|
)
|
|
|
|
if ($BuildStamp.Date -lt $Epoch) {
|
|
throw "Build date '$($BuildStamp.Date.ToString("yyyy-MM-dd"))' is before ReleaseTrainEpoch '$($Epoch.ToString("yyyy-MM-dd"))'"
|
|
}
|
|
|
|
if ($DailySequence -lt 1 -or $DailySequence -gt 9) {
|
|
throw "Daily release sequence '$DailySequence' is outside the YDDDB-supported range 1-9"
|
|
}
|
|
|
|
$yearOffset = $BuildStamp.Date.Year - $Epoch.Year
|
|
if ($yearOffset -gt 6) {
|
|
throw "Release train year offset '$yearOffset' exceeds the MSI-safe YDDDB range 0-6; advance the release train and reset ReleaseTrainEpoch"
|
|
}
|
|
|
|
$thirdComponentText = "{0}{1:D3}{2}" -f $yearOffset, $BuildStamp.Date.DayOfYear, $DailySequence
|
|
$thirdComponent = [int]::Parse($thirdComponentText)
|
|
if ($thirdComponent -gt [UInt16]::MaxValue) {
|
|
throw "Generated version component '$thirdComponent' exceeds 65535; advance the release train and reset ReleaseTrainEpoch"
|
|
}
|
|
|
|
return "$ReleaseTrain.$thirdComponent.0"
|
|
}
|
|
|
|
function Get-PrivateVersion {
|
|
param(
|
|
[Parameter(Mandatory)][datetime]$Epoch,
|
|
[Parameter(Mandatory)]$BuildStamp
|
|
)
|
|
|
|
$extendedDay = ($BuildStamp.Date - $Epoch).Days + 1
|
|
if ($extendedDay -lt 1) {
|
|
throw "Build date '$($BuildStamp.Date.ToString("yyyy-MM-dd"))' is before ReleaseTrainEpoch '$($Epoch.ToString("yyyy-MM-dd"))'"
|
|
}
|
|
|
|
$thirdComponent = ($extendedDay * 100) + $BuildStamp.Revision
|
|
if ($thirdComponent -gt [UInt16]::MaxValue) {
|
|
throw "Generated private version component '$thirdComponent' exceeds 65535"
|
|
}
|
|
|
|
return "0.0.$thirdComponent.0"
|
|
}
|
|
|
|
function Get-ReleaseDailySequence {
|
|
param([AllowEmptyString()][string]$Sequence)
|
|
|
|
if ([string]::IsNullOrWhiteSpace($Sequence)) {
|
|
throw "DailyVersionSequence is required for main and stable builds"
|
|
}
|
|
|
|
if ($Sequence -notmatch "^\d+$") {
|
|
throw "Daily release sequence '$Sequence' must be numeric"
|
|
}
|
|
|
|
return [int]::Parse($Sequence)
|
|
}
|
|
|
|
function Get-AutomaticReleaseVersion {
|
|
param(
|
|
[Parameter(Mandatory)][string]$ReleaseTrain,
|
|
[Parameter(Mandatory)][datetime]$Epoch,
|
|
[Parameter(Mandatory)]$BuildStamp,
|
|
[Parameter(Mandatory)][AllowEmptyString()][string]$DateOverride,
|
|
[Parameter(Mandatory)][AllowEmptyString()][string]$DailySequence
|
|
)
|
|
|
|
$releaseBuildStamp = [pscustomobject]@{
|
|
Date = Get-VersionDate -DateOverride $DateOverride -BuildStamp $BuildStamp
|
|
Revision = $BuildStamp.Revision
|
|
}
|
|
$releaseDailySequence = Get-ReleaseDailySequence -Sequence $DailySequence
|
|
return Get-ReleaseVersion -ReleaseTrain $ReleaseTrain -Epoch $Epoch -BuildStamp $releaseBuildStamp -DailySequence $releaseDailySequence
|
|
}
|
|
|
|
function Get-PreviewVersionOverride {
|
|
param(
|
|
[Parameter(Mandatory)][string]$ReleaseTrain,
|
|
[AllowEmptyString()][string]$Override
|
|
)
|
|
|
|
$inputVersion = $Override.Trim()
|
|
if ($inputVersion.EndsWith("-preview", [StringComparison]::OrdinalIgnoreCase)) {
|
|
$inputVersion = $inputVersion.Substring(0, $inputVersion.Length - "-preview".Length)
|
|
}
|
|
|
|
if ([string]::IsNullOrWhiteSpace($inputVersion)) {
|
|
return $null
|
|
}
|
|
|
|
if ($inputVersion -match "^(?<major>\d+)\.(?<minor>\d+)$") {
|
|
if ($inputVersion -ne $ReleaseTrain) {
|
|
throw "Preview version base '$inputVersion' does not match ReleaseTrainVersion '$ReleaseTrain'"
|
|
}
|
|
|
|
return $null
|
|
}
|
|
|
|
if ($inputVersion -notmatch "^(?<major>\d+)\.(?<minor>\d+)\.(?<revision>\d+)\.(?<build>\d+)$") {
|
|
throw "Preview version override must be major.minor or major.minor.YDDDB.0, optionally followed by -preview"
|
|
}
|
|
|
|
if ("$($matches["major"]).$($matches["minor"])" -ne $ReleaseTrain) {
|
|
throw "Preview version '$inputVersion' does not match ReleaseTrainVersion '$ReleaseTrain'"
|
|
}
|
|
|
|
if ([int]::Parse($matches["build"]) -ne 0) {
|
|
throw "Preview version '$inputVersion' must use 0 for the fourth component"
|
|
}
|
|
|
|
Test-VersionParts -Parts @($matches["major"], $matches["minor"], $matches["revision"], $matches["build"])
|
|
$parts = @($matches["major"], $matches["minor"], $matches["revision"], $matches["build"])
|
|
return ($parts | ForEach-Object { [int]::Parse($_) }) -join "."
|
|
}
|
|
|
|
function Get-MsiSafeVersionOverride {
|
|
param(
|
|
[Parameter(Mandatory)][string]$Override,
|
|
[Parameter(Mandatory)][string]$VersionKind
|
|
)
|
|
|
|
$inputVersion = $Override.Trim()
|
|
if ($inputVersion -notmatch "^(?<major>\d+)\.(?<minor>\d+)\.(?<revision>\d+)(?:\.(?<build>\d+))?$") {
|
|
throw "$VersionKind version override must be numeric major.minor.patch or major.minor.patch.build"
|
|
}
|
|
|
|
$parts = @($matches["major"], $matches["minor"], $matches["revision"])
|
|
if ($matches["build"]) {
|
|
$parts += $matches["build"]
|
|
}
|
|
else {
|
|
$parts += "0"
|
|
}
|
|
|
|
Test-VersionParts -Parts $parts
|
|
if ([int]::Parse($parts[0]) -gt 255 -or [int]::Parse($parts[1]) -gt 255) {
|
|
throw "$VersionKind version '$inputVersion' must keep major and minor within the MSI-supported range 0-255"
|
|
}
|
|
|
|
if ([int]::Parse($parts[3]) -ne 0) {
|
|
throw "$VersionKind version '$inputVersion' must use 0 for the fourth component"
|
|
}
|
|
|
|
return ($parts | ForEach-Object { [int]::Parse($_) }) -join "."
|
|
}
|
|
|
|
function Get-StableVersionOverride {
|
|
param(
|
|
[Parameter(Mandatory)][AllowEmptyString()][string]$Override
|
|
)
|
|
|
|
if ([string]::IsNullOrWhiteSpace($Override)) {
|
|
return $null
|
|
}
|
|
|
|
return Get-MsiSafeVersionOverride -Override $Override -VersionKind "Stable"
|
|
}
|
|
|
|
$isMain = $SourceBranch -eq "refs/heads/main"
|
|
$isStable = $SourceBranch -eq "refs/heads/stable"
|
|
$isScheduled = $BuildReason -eq "Schedule"
|
|
|
|
if ($ReleaseIntent -eq "stable-release" -and -not $isStable) {
|
|
throw "Stable release intent is only supported from refs/heads/stable"
|
|
}
|
|
if ($ReleaseIntent -eq "preview-release" -and -not ($isMain -or $isStable)) {
|
|
throw "Preview release intent is only supported from refs/heads/main or refs/heads/stable"
|
|
}
|
|
if ($isScheduled -and $isStable -and $ReleaseIntent -ne "preview-release") {
|
|
throw "Scheduled stable builds must explicitly use preview-release intent"
|
|
}
|
|
if ($isScheduled -and -not ($isMain -or $isStable)) {
|
|
throw "Scheduled release builds are only supported from refs/heads/main or refs/heads/stable"
|
|
}
|
|
|
|
$releaseMetadata = Get-ReleaseTrainMetadata -Path $VersionPropsPath
|
|
$releaseTrain = $releaseMetadata.Version
|
|
$buildStamp = Get-BuildStamp -PipelineBuildNumber $BuildNumber
|
|
|
|
if ($isMain) {
|
|
if ($isScheduled -and -not [string]::IsNullOrWhiteSpace($VersionOverride)) {
|
|
throw "Scheduled main builds must use the checked-in ReleaseTrainVersion and cannot specify a version override"
|
|
}
|
|
|
|
$intent = if ($isScheduled -or $ReleaseIntent -eq "preview-release") { "preview-release" } else { "preview-validation" }
|
|
$channel = "preview"
|
|
$version = Get-PreviewVersionOverride -ReleaseTrain $releaseTrain -Override $VersionOverride
|
|
if ($null -eq $version) {
|
|
$version = Get-AutomaticReleaseVersion `
|
|
-ReleaseTrain $releaseTrain `
|
|
-Epoch $releaseMetadata.Epoch `
|
|
-BuildStamp $buildStamp `
|
|
-DateOverride $BuildDate `
|
|
-DailySequence $DailyVersionSequence
|
|
}
|
|
$allowPublicSymbols = $false
|
|
$shouldPublishPreview = $intent -eq "preview-release"
|
|
}
|
|
elseif ($isStable) {
|
|
if ($ReleaseIntent -eq "preview-release") {
|
|
$intent = "preview-release"
|
|
$channel = "preview"
|
|
$version = Get-PreviewVersionOverride -ReleaseTrain $releaseTrain -Override $VersionOverride
|
|
$allowPublicSymbols = $false
|
|
$shouldPublishPreview = $true
|
|
}
|
|
else {
|
|
$intent = "stable-release"
|
|
$channel = "stable"
|
|
$version = Get-StableVersionOverride -Override $VersionOverride
|
|
$allowPublicSymbols = $true
|
|
$shouldPublishPreview = $false
|
|
}
|
|
|
|
if ($null -eq $version) {
|
|
$version = Get-AutomaticReleaseVersion `
|
|
-ReleaseTrain $releaseTrain `
|
|
-Epoch $releaseMetadata.Epoch `
|
|
-BuildStamp $buildStamp `
|
|
-DateOverride $BuildDate `
|
|
-DailySequence $DailyVersionSequence
|
|
}
|
|
}
|
|
else {
|
|
$intent = "private-validation"
|
|
$channel = "private"
|
|
$version = if ([string]::IsNullOrWhiteSpace($VersionOverride)) {
|
|
Get-PrivateVersion -Epoch $releaseMetadata.Epoch -BuildStamp $buildStamp
|
|
}
|
|
else {
|
|
Get-MsiSafeVersionOverride -Override $VersionOverride -VersionKind "Private"
|
|
}
|
|
$allowPublicSymbols = $false
|
|
$shouldPublishPreview = $false
|
|
}
|
|
|
|
Test-VersionParts -Parts ($version -split "\.")
|
|
|
|
Write-Host "Resolved build intent: $intent"
|
|
Write-Host "Resolved release channel: $channel"
|
|
Write-Host "Resolved version: $version"
|
|
|
|
[pscustomobject]@{
|
|
Intent = $intent
|
|
Channel = $channel
|
|
Version = $version
|
|
AllowPublicSymbols = $allowPublicSymbols
|
|
ShouldPublishPreview = $shouldPublishPreview
|
|
}
|