From ba37dbd56bc6ed2c1d8853e91fcdd8efe8a11513 Mon Sep 17 00:00:00 2001 From: Alan Zimmer <48699787+alzimmermsft@users.noreply.github.com> Date: Fri, 30 Oct 2026 13:45:14 -0400 Subject: [PATCH] Add DevOps chron job to automate Azure.Mcp release prep (#4067) * Add DevOps chron job to automate Azure.Mcp release prep * Clean up testing a bit * Fix GitHub pull request retrieval logic Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- eng/README.md | 11 + eng/pipelines/release-prep.yml | 93 ++++ eng/scripts/Prepare-ReleasePullRequest.ps1 | 28 ++ .../helpers/ReleasePrepHelpers.Tests.ps1 | 455 ++++++++++++++++++ eng/scripts/helpers/ReleasePrepHelpers.ps1 | 281 +++++++++++ .../scheduled-release-preparation.yaml | 3 + 6 files changed, 871 insertions(+) create mode 100644 eng/pipelines/release-prep.yml create mode 100644 eng/scripts/Prepare-ReleasePullRequest.ps1 create mode 100644 eng/scripts/helpers/ReleasePrepHelpers.Tests.ps1 create mode 100644 eng/scripts/helpers/ReleasePrepHelpers.ps1 create mode 100644 servers/Azure.Mcp.Server/changelog-entries/scheduled-release-preparation.yaml diff --git a/eng/README.md b/eng/README.md index b25991c3c..53a1da9c7 100644 --- a/eng/README.md +++ b/eng/README.md @@ -218,6 +218,17 @@ For design details, see [docs/design/mcpb-packaging-and-signing-via-esrp.md](htt We only ship the repo's server projects, with each server stored under `servers/`. The release pipelines are driven by the server-specific `build.yml` files (for example, `servers/Azure.Mcp.Server/build.yml`). Those files opt a server into the artifact types we publish and pass the settings that flow through `/eng/pipelines/templates/common.yml`. +## Scheduled Azure MCP release preparation + +[release-prep.yml](https://github.com/microsoft/mcp/blob/main/eng/pipelines/release-prep.yml) opens an Azure MCP Server release-preparation PR every Tuesday and Thursday at **12 PM Pacific**, except on Microsoft US holidays. It does not merge PRs, publish packages, or queue the release pipeline. + +- Azure DevOps uses UTC schedules, so the pipeline has 19:00 and 20:00 UTC triggers. A Pacific time-zone check selects exactly one trigger for the current daylight-saving offset, including when agent allocation delays the job within the scheduled Pacific day. The inactive trigger exits without requesting GitHub credentials. +- The calendar excludes these 10 Microsoft US holidays: New Year's Day, Martin Luther King Jr. Day, Washington's Birthday, Memorial Day, Independence Day, Labor Day, Thanksgiving Day, the day after Thanksgiving, Christmas Eve, and Christmas Day. Fixed-date holidays are excluded on both their actual and observed dates. Saturday holidays are observed on Friday and Sunday holidays on Monday, including New Year's Day observed in the previous December. Holiday runs are skipped, not moved to another day. +- The `ReleaseOwners` parameter is the list of GitHub usernames, without `@`, to mention in the PR description. The description asks them to review, approve and merge the PR after checks pass, then start Azure.Mcp release. +- Preparation uses the existing version in the Azure MCP Server project and requires a matching `Unreleased` changelog heading. Already-finalized versions and runs without changelog entries are skipped; merge the normal post-release version-increment PR before the next release. +- The job compiles both changelogs, removes the compiled entry files, and opens a PR from a version-specific branch in `azure-sdk/mcp`. An existing open preparation PR for that version is left unchanged, preserving reviewer edits. +- The repository PR template is preserved, with a pipeline link and validation results added. Required PR checks still run normally; preparation does not claim that builds or tests have passed. + ## Queuing a release run - Manually queue the server pipeline (e.g. [`mcp - Azure.Mcp.Server`](https://dev.azure.com/azure-sdk/internal/_build?definitionId=7866&_a=summary)) with `PublishTarget` set to `none`, `internal`, or `public`. - `none` builds and validates only. Outputs are saved as unsigned pipeline artifacts. Servers will be given a prerelease version based on the version in the servers' project file, with a suffix like `-alpha.`. diff --git a/eng/pipelines/release-prep.yml b/eng/pipelines/release-prep.yml new file mode 100644 index 000000000..7e2bc194c --- /dev/null +++ b/eng/pipelines/release-prep.yml @@ -0,0 +1,93 @@ +# cSpell:ignore alzimmermsft g2vinay msalaman CRONSCHEDULE COLLECTIONURI TEAMPROJECT +trigger: none +pr: none + +parameters: +- name: ReleaseOwners + displayName: GitHub aliases to mention for release follow-up + type: object + default: + - alzimmermsft + - g2vinay + - msalaman + +# Azure DevOps schedules use UTC. The calendar check selects the active Pacific offset. +schedules: +- cron: '0 19 * * 2,4' + displayName: Release preparation (PDT) + branches: + include: + - main + always: true +- cron: '0 20 * * 2,4' + displayName: Release preparation (PST) + branches: + include: + - main + always: true + +extends: + template: /eng/pipelines/templates/1es-redirect.yml + parameters: + stages: + - stage: ReleasePreparation + displayName: Prepare Azure MCP release pull request + condition: and(succeeded(), eq(variables['System.TeamProject'], 'internal'), eq(variables['Build.Repository.Name'], 'microsoft/mcp'), eq(variables['Build.SourceBranch'], 'refs/heads/main')) + pool: + name: $(WINDOWSPOOL) + image: $(WINDOWSVMIMAGE) + os: windows + variables: + - template: /eng/pipelines/templates/variables/image.yml + - template: /eng/pipelines/templates/variables/globals.yml + jobs: + - job: CheckSchedule + displayName: Check Pacific time and US federal holidays + condition: and(succeeded(), eq(variables['Build.Reason'], 'Schedule')) + timeoutInMinutes: 10 + steps: + - checkout: self + - pwsh: | + $ErrorActionPreference = 'Stop' + . .\eng\scripts\helpers\ReleasePrepHelpers.ps1 + + $decision = Get-ReleasePrepSchedule -ScheduleName $env:BUILD_CRONSCHEDULE_DISPLAYNAME + Write-Host $decision.Reason + Write-Host "##vso[task.setvariable variable=ShouldPrepareRelease;isOutput=true]$($decision.ShouldRun.ToString().ToLowerInvariant())" + Write-Host "##vso[task.setvariable variable=ReleaseDate;isOutput=true]$($decision.ReleaseDate)" + name: schedule + displayName: Evaluate release calendar + workingDirectory: $(Build.SourcesDirectory) + + - job: PrepareRelease + displayName: Create release-preparation pull request + dependsOn: CheckSchedule + condition: and(succeeded(), eq(dependencies.CheckSchedule.outputs['schedule.ShouldPrepareRelease'], 'true')) + timeoutInMinutes: 30 + variables: + - name: ReleaseDate + value: $[ dependencies.CheckSchedule.outputs['schedule.ReleaseDate'] ] + steps: + - checkout: self + fetchDepth: 0 + - template: /eng/common/pipelines/templates/steps/login-to-github.yml + parameters: + TokenOwners: + - azure-sdk + - microsoft + - pwsh: | + $ErrorActionPreference = 'Stop' + $releaseOwners = @($env:RELEASE_OWNERS | ConvertFrom-Json) + $buildUrl = '{0}{1}/_build/results?buildId={2}' -f ` + $env:SYSTEM_COLLECTIONURI, [Uri]::EscapeDataString($env:SYSTEM_TEAMPROJECT), $env:BUILD_BUILDID + .\eng\scripts\Prepare-ReleasePullRequest.ps1 ` + -ReleaseOwners $releaseOwners ` + -ReleaseDate $env:RELEASE_DATE ` + -BuildUrl $buildUrl + displayName: Compile changelogs and open release-preparation PR + workingDirectory: $(Build.SourcesDirectory) + env: + RELEASE_OWNERS: ${{ convertToJson(parameters.ReleaseOwners) }} + RELEASE_DATE: $(ReleaseDate) + GH_TOKEN: $(GH_TOKEN_microsoft) + GH_PUSH_TOKEN: $(GH_TOKEN_azure-sdk) diff --git a/eng/scripts/Prepare-ReleasePullRequest.ps1 b/eng/scripts/Prepare-ReleasePullRequest.ps1 new file mode 100644 index 000000000..768fb3de3 --- /dev/null +++ b/eng/scripts/Prepare-ReleasePullRequest.ps1 @@ -0,0 +1,28 @@ +#!/usr/bin/env pwsh +#Requires -Version 7 + +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string[]]$ReleaseOwners, + + [Parameter(Mandatory)] + [datetime]$ReleaseDate, + + [Parameter(Mandatory)] + [uri]$BuildUrl +) + +$ErrorActionPreference = 'Stop' + +. "$PSScriptRoot\..\common\scripts\common.ps1" +. "$PSScriptRoot\helpers\ReleasePrepHelpers.ps1" + +Invoke-ReleasePrepPullRequest ` + -RepositoryRoot $RepoRoot.Path ` + -ReleaseOwners $ReleaseOwners ` + -ReleaseDate $ReleaseDate ` + -BuildUrl $BuildUrl ` + -GitHubToken $env:GH_TOKEN ` + -PushToken $env:GH_PUSH_TOKEN diff --git a/eng/scripts/helpers/ReleasePrepHelpers.Tests.ps1 b/eng/scripts/helpers/ReleasePrepHelpers.Tests.ps1 new file mode 100644 index 000000000..19ae32f3d --- /dev/null +++ b/eng/scripts/helpers/ReleasePrepHelpers.Tests.ps1 @@ -0,0 +1,455 @@ +# cSpell:ignore Juneteenth +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidGlobalVars', '', Justification = 'Fixture scripts share mock call state across script scopes, and AfterAll removes that state.')] +param() + +BeforeAll { + . "$PSScriptRoot\..\..\common\scripts\common.ps1" + . "$PSScriptRoot\ReleasePrepHelpers.ps1" + Import-Module powershell-yaml -MinimumVersion 0.4.7 -ErrorAction Stop + + $script:PrTemplate = Get-Content (Join-Path $RepoRoot '.github\PULL_REQUEST_TEMPLATE.md') -Raw + $script:Pipeline = Get-Content (Join-Path $RepoRoot 'eng\pipelines\release-prep.yml') -Raw | ConvertFrom-Yaml +} + +Describe 'Microsoft US holiday calendar' { + It 'recognizes on ' -ForEach @( + @{ Date = '2026-01-01'; Name = "New Year's Day" } + @{ Date = '2026-01-19'; Name = 'Martin Luther King Jr. Day' } + @{ Date = '2026-02-16'; Name = "Washington's Birthday" } + @{ Date = '2026-05-25'; Name = 'Memorial Day' } + @{ Date = '2026-07-04'; Name = 'Independence Day' } + @{ Date = '2026-09-07'; Name = 'Labor Day' } + @{ Date = '2026-11-26'; Name = 'Thanksgiving Day' } + @{ Date = '2026-11-27'; Name = 'Day after Thanksgiving' } + @{ Date = '2026-12-24'; Name = 'Christmas Eve' } + @{ Date = '2026-12-25'; Name = 'Christmas Day' } + ) { + Get-USFederalHoliday -Date ([datetime]$Date) | Should -Be $Name + } + + It 'recognizes exactly 10 distinct Microsoft holidays' { + $holidayNames = for ($date = [datetime]::new(2026, 1, 1); $date -lt [datetime]::new(2027, 1, 1); $date = $date.AddDays(1)) { + $holiday = Get-USFederalHoliday -Date $date + if ($holiday) { + $holiday -replace ' \(observed\)$', '' + } + } + @($holidayNames | Sort-Object -Unique).Count | Should -Be 10 + } + + It 'recognizes the observed date of on ' -ForEach @( + @{ Date = '2026-07-03'; Name = 'Independence Day (observed)' } + @{ Date = '2027-07-05'; Name = 'Independence Day (observed)' } + @{ Date = '2023-01-02'; Name = "New Year's Day (observed)" } + @{ Date = '2021-12-31'; Name = "New Year's Day (observed)" } + @{ Date = '2022-12-23'; Name = 'Christmas Eve (observed)' } + @{ Date = '2022-12-26'; Name = 'Christmas Day (observed)' } + ) { + Get-USFederalHoliday -Date ([datetime]$Date) | Should -Be $Name + } + + It 'recognizes Memorial Day when the last Monday is the fifth Monday' { + Get-USFederalHoliday -Date '2027-05-31' | Should -Be 'Memorial Day' + } + + It "recognizes New Year's Day on its actual weekend date" { + Get-USFederalHoliday -Date '2022-01-01' | Should -Be "New Year's Day" + } + + It 'recognizes the Friday immediately after Thanksgiving on ' -ForEach @( + @{ Date = '2018-11-23' } + @{ Date = '2024-11-29' } + @{ Date = '2030-11-29' } + ) { + Get-USFederalHoliday -Date ([datetime]$Date) | Should -Be 'Day after Thanksgiving' + } + + It 'does not exclude on ' -ForEach @( + @{ Date = '2026-06-19'; Name = 'Juneteenth' } + @{ Date = '2026-10-12'; Name = 'Columbus Day' } + @{ Date = '2026-11-11'; Name = 'Veterans Day' } + @{ Date = '2026-04-03'; Name = 'Good Friday' } + @{ Date = '2026-11-03'; Name = 'an ordinary Tuesday' } + @{ Date = '2027-05-24'; Name = 'the Monday before Memorial Day' } + @{ Date = '2024-11-22'; Name = 'the Friday before Thanksgiving' } + @{ Date = '2030-11-22'; Name = 'the Friday before Thanksgiving' } + @{ Date = '2018-11-30'; Name = 'the Friday a week after Thanksgiving' } + ) { + Get-USFederalHoliday -Date ([datetime]$Date) | Should -BeNullOrEmpty + } +} + +Describe 'Pacific release-preparation schedules' { + It 'selects exactly one noon-Pacific trigger on every eligible day from 2026 through 2030' { + $scheduledHolidayDates = @( + '2026-01-01', '2026-11-26', '2026-12-24', + '2027-11-25', + '2028-07-04', '2028-11-23', + '2029-11-22', '2029-12-25', + '2030-01-01', '2030-07-04', '2030-11-28', '2030-12-24' + ) + $zone = [TimeZoneInfo]::FindSystemTimeZoneById('Pacific Standard Time') + for ($date = [datetime]::new(2026, 1, 1); $date -lt [datetime]::new(2031, 1, 1); $date = $date.AddDays(1)) { + if ($date.DayOfWeek -notin @([DayOfWeek]::Tuesday, [DayOfWeek]::Thursday)) { + continue + } + $eligibleSchedules = @( + foreach ($schedule in $script:Pipeline.schedules) { + $hour = [int]($schedule.cron -split ' ')[1] + $utcTime = [DateTimeOffset]::new($date.AddHours($hour), [timespan]::Zero) + $decision = Get-ReleasePrepSchedule -ScheduleName $schedule.displayName -UtcNow $utcTime + if ($decision.ShouldRun) { + [TimeZoneInfo]::ConvertTime($utcTime, $zone).Hour | Should -Be 12 -Because "$date must run at noon Pacific" + $decision.ReleaseDate | Should -Be $date.ToString('yyyy-MM-dd') + $schedule + } + } + ) + $expectedCount = if ($date.ToString('yyyy-MM-dd') -in $scheduledHolidayDates) { 0 } else { 1 } + $eligibleSchedules.Count | Should -Be $expectedCount -Because "the calendar must allow exactly $expectedCount trigger(s) on $date" + } + } + + It 'does not run on a Wednesday' { + $decision = Get-ReleasePrepSchedule -ScheduleName 'Release preparation (PDT)' -UtcNow '2026-10-28T19:00:00Z' + $decision.ShouldRun | Should -BeFalse + $decision.Reason | Should -Match 'not a Tuesday or Thursday' + } + + It 'skips Thanksgiving using the Pacific date rather than the UTC date' { + $decision = Get-ReleasePrepSchedule -ScheduleName 'Release preparation (PST)' -UtcNow '2026-11-27T01:00:00Z' + $decision.ShouldRun | Should -BeFalse + $decision.ReleaseDate | Should -Be '2026-11-26' + $decision.Reason | Should -Match 'Thanksgiving Day' + } + + It 'uses the selected schedule rather than the delayed agent start hour' { + $decision = Get-ReleasePrepSchedule -ScheduleName 'Release preparation (PDT)' -UtcNow '2026-10-29T20:45:00Z' + $decision.ShouldRun | Should -BeTrue + } + + It 'rejects an unrecognized schedule instead of choosing a default offset' { + { Get-ReleasePrepSchedule -ScheduleName 'Unknown schedule' } | Should -Throw '*Unrecognized release-preparation schedule*' + } +} + +Describe 'Release-preparation PR description' { + BeforeEach { + $script:BodyParameters = @{ + Template = $script:PrTemplate + Version = '4.0.0-beta.5' + ReleaseDate = [datetime]'2026-11-03' + ReleaseOwners = @('owner-one', 'owner-two') + BuildUrl = [uri]'https://dev.azure.com/azure-sdk/internal/_build/results?buildId=123' + } + } + + It 'mentions every configured owner and describes the manual release follow-up' { + $body = Format-ReleasePrepPullRequestBody @script:BodyParameters + $body | Should -Match '@owner-one @owner-two' + $body | Should -Match 'approve and merge this PR after required checks pass' + $body | Should -Match 'PublishTarget=public' + $body | Should -Match 'using the merged commit' + $body | Should -Match 'does not merge the PR or start a release' + $body | Should -Match '4\.0\.0-beta\.5' + $body | Should -Match '2026-11-03' + $body | Should -Match 'https://dev.azure.com/azure-sdk/internal/_build/results\?buildId=123' + } + + It 'renders every alias configured in the pipeline defaults' { + $script:BodyParameters.ReleaseOwners = @($script:Pipeline.parameters[0].default) + $body = Format-ReleasePrepPullRequestBody @script:BodyParameters + foreach ($owner in $script:BodyParameters.ReleaseOwners) { + $body.Contains("@$owner") | Should -BeTrue + } + } + + It 'preserves all template headings and checklist items in their original order' { + $body = Format-ReleasePrepPullRequestBody @script:BodyParameters + $originalHeadings = @([regex]::Matches($script:PrTemplate, '(?m)^## .+\r?$') | ForEach-Object { $_.Value.TrimEnd() }) + $bodyHeadings = @([regex]::Matches($body, '(?m)^## .+\r?$') | ForEach-Object { $_.Value.TrimEnd() }) + ($bodyHeadings[0..($originalHeadings.Count - 1)] -join "`n") | Should -Be ($originalHeadings -join "`n") + + $originalChecklist = [regex]::Matches($script:PrTemplate, '(?m)^[ \t]*- \[[ x]\].+\r?$').Value -join "`n" + $bodyChecklist = [regex]::Matches($body, '(?m)^[ \t]*- \[[ x]\].+\r?$').Value -join "`n" + $bodyChecklist | Should -Be $originalChecklist + $body | Should -Not -Match '\[Provide a clear|\[Add additional|\[Link to the GitHub issue' + $bodyHeadings[-1] | Should -Be '## Invoking Livetests' + } + + It 'records the exact compilation command and truthful validation results' { + $body = Format-ReleasePrepPullRequestBody @script:BodyParameters + $command = ".\eng\scripts\Compile-Changelog.ps1 -ChangelogPath 'servers\Azure.Mcp.Server\CHANGELOG.md'" + $body.Contains("``$command``: passed") | Should -BeTrue + $body | Should -Match 'Builds and tests: not run' + } + + It 'rejects invalid aliases before any release changes' -ForEach @( + @{ Alias = '@owner-one' } + @{ Alias = 'owner one' } + @{ Alias = 'owner/another' } + @{ Alias = "owner`nInjected text" } + ) { + $script:BodyParameters.ReleaseOwners = @($Alias) + { Format-ReleasePrepPullRequestBody @script:BodyParameters } | Should -Throw '*Invalid GitHub alias*' + } + + It 'requires at least one follow-up owner' { + $script:BodyParameters.ReleaseOwners = @() + { Format-ReleasePrepPullRequestBody @script:BodyParameters } | Should -Throw + } + + It 'fails explicitly if the repository template changes unexpectedly' { + $script:BodyParameters.Template = '## Different template' + { Format-ReleasePrepPullRequestBody @script:BodyParameters } | Should -Throw '*missing the expected placeholder*' + } + + It 'rejects a non-HTTPS pipeline link' { + $script:BodyParameters.BuildUrl = [uri]'http://example.test/build' + { Format-ReleasePrepPullRequestBody @script:BodyParameters } | Should -Throw '*absolute HTTPS URL*' + } +} + +Describe 'Release-preparation changelog compilation' { + It 'finalizes both changelogs and removes entries using the real compiler without changing the version' { + $fixtureRelativePath = Join-Path '.work' "release-prep-compile-$([guid]::NewGuid().ToString('N'))" + $fixtureRoot = Join-Path $RepoRoot $fixtureRelativePath + $relativeServerPath = Join-Path $fixtureRelativePath 'Azure.Mcp.Server' + $serverPath = Join-Path $RepoRoot $relativeServerPath + try { + New-Item -ItemType Directory -Force -Path @( + (Join-Path $serverPath 'src'), + (Join-Path $serverPath 'changelog-entries'), + (Join-Path $serverPath 'vscode') + ) | Out-Null + Set-Content (Join-Path $serverPath 'src\Azure.Mcp.Server.csproj') '4.0.0-beta.5' + Set-Content (Join-Path $serverPath 'CHANGELOG.md') "# CHANGELOG`n`n## 4.0.0-beta.5 (Unreleased)`n" + Set-Content (Join-Path $serverPath 'vscode\CHANGELOG.md') "# Release History`n`n## 4.0.4 (2026-10-29) (pre-release)`n`nPrevious release.`n" + Set-Content (Join-Path $serverPath 'vscode\package.json') '{"publisher":"ms-azuretools","name":"vscode-azure-mcp-server"}' + @' +pr: 1 +changes: + - section: Features Added + description: Added a release-preparation integration test. +'@ | Set-Content (Join-Path $serverPath 'changelog-entries\test-entry.yaml') + + $global:LASTEXITCODE = 0 + & (Join-Path $RepoRoot 'eng\scripts\Compile-Changelog.ps1') ` + -ChangelogPath (Join-Path $relativeServerPath 'CHANGELOG.md') + + $LASTEXITCODE | Should -Be 0 + $mainChangelog = Get-Content (Join-Path $serverPath 'CHANGELOG.md') -Raw + $mainChangelog | Should -Match '## 4\.0\.0-beta\.5 \(\d{4}-\d{2}-\d{2}\)' + $mainChangelog | Should -Not -Match 'Unreleased' + $extensionChangelog = Get-Content (Join-Path $serverPath 'vscode\CHANGELOG.md') -Raw + $extensionChangelog | Should -Match '## 4\.0\.5 \(\d{4}-\d{2}-\d{2}\) \(pre-release\)' + $extensionChangelog | Should -Match 'Added a release-preparation integration test' + Test-Path (Join-Path $serverPath 'changelog-entries\test-entry.yaml') | Should -BeFalse + Get-Content (Join-Path $serverPath 'src\Azure.Mcp.Server.csproj') -Raw | + Should -Match '4\.0\.0-beta\.5' + } + finally { + if (Test-Path -LiteralPath $fixtureRoot) { + Remove-Item -LiteralPath $fixtureRoot -Recurse -Force + } + } + } +} + +Describe 'Release-preparation orchestration' { + BeforeEach { + $script:FixtureRoot = Join-Path $TestDrive 'repo' + $serverPath = Join-Path $script:FixtureRoot 'servers\Azure.Mcp.Server' + New-Item -ItemType Directory -Force -Path @( + (Join-Path $serverPath 'src'), + (Join-Path $serverPath 'changelog-entries'), + (Join-Path $script:FixtureRoot '.github'), + (Join-Path $script:FixtureRoot 'eng\scripts'), + (Join-Path $script:FixtureRoot 'eng\common\scripts') + ) | Out-Null + Set-Content (Join-Path $serverPath 'src\Azure.Mcp.Server.csproj') '4.0.0-beta.5' + Set-Content (Join-Path $serverPath 'CHANGELOG.md') '## 4.0.0-beta.5 (Unreleased)' + Set-Content (Join-Path $serverPath 'changelog-entries\test-entry.yaml') 'changes: []' + Set-Content (Join-Path $script:FixtureRoot '.github\PULL_REQUEST_TEMPLATE.md') $script:PrTemplate + + $global:ReleasePrepCalls = [Collections.Generic.List[object]]::new() + $global:ReleasePrepCompileExitCode = 0 + $global:ReleasePrepPushExitCode = 0 + $global:ReleasePrepSubmitExitCode = 0 + @' +param($ChangelogPath, $Version) +if (-not (Test-Path (Join-Path "$PSScriptRoot\..\.." $ChangelogPath))) { + throw 'The compiler requires a repository-relative changelog path.' +} +$global:ReleasePrepCalls.Add(@{ Kind = 'Compile'; ChangelogPath = $ChangelogPath; Version = $Version }) +$global:LASTEXITCODE = $global:ReleasePrepCompileExitCode +'@ | Set-Content (Join-Path $script:FixtureRoot 'eng\scripts\Compile-Changelog.ps1') + @' +param($PRBranchName, $CommitMsg, $GitUrl) +$global:ReleasePrepCalls.Add(@{ Kind = 'Push'; Branch = $PRBranchName; Message = $CommitMsg; Url = $GitUrl }) +$global:LASTEXITCODE = $global:ReleasePrepPushExitCode +'@ | Set-Content (Join-Path $script:FixtureRoot 'eng\common\scripts\git-branch-push.ps1') + @' +param($RepoOwner, $RepoName, $BaseBranch, $PROwner, $PRBranch, $AuthToken, $PRTitle, $PRBody, $MaintainerCanModify) +$global:ReleasePrepCalls.Add(@{ + Kind = 'Submit'; RepoOwner = $RepoOwner; RepoName = $RepoName; BaseBranch = $BaseBranch + PROwner = $PROwner; Branch = $PRBranch; Title = $PRTitle; Body = $PRBody +}) +$global:LASTEXITCODE = $global:ReleasePrepSubmitExitCode +'@ | Set-Content (Join-Path $script:FixtureRoot 'eng\common\scripts\Submit-PullRequest.ps1') + + $script:StatusOutput = @() + $script:StatusExitCode = 0 + $script:DiffExitCode = 1 + Mock git { + switch ($args[0]) { + 'status' { + $global:LASTEXITCODE = $script:StatusExitCode + $script:StatusOutput + } + 'diff' { $global:LASTEXITCODE = $script:DiffExitCode } + default { throw "Unexpected git command: $args" } + } + } + Mock Get-GitHubPullRequests { @() } + $script:PrepParameters = @{ + RepositoryRoot = $script:FixtureRoot + ReleaseOwners = @('owner-one', 'owner-two') + ReleaseDate = [datetime]'2026-11-03' + BuildUrl = [uri]'https://dev.azure.com/azure-sdk/internal/_build/results?buildId=123' + GitHubToken = 'test-github-token' + PushToken = 'test-push-token' + } + } + + It 'compiles the checked-in version and creates a version-specific PR with the configured mentions' { + Invoke-ReleasePrepPullRequest @script:PrepParameters + + ($global:ReleasePrepCalls.Kind -join ',') | Should -Be 'Compile,Push,Submit' + $global:ReleasePrepCalls[0].Version | Should -BeNullOrEmpty + $global:ReleasePrepCalls[0].ChangelogPath | Should -Be 'servers\Azure.Mcp.Server\CHANGELOG.md' + $global:ReleasePrepCalls[1].Branch | Should -Be 'prepare-release-Azure.Mcp.Server-4.0.0-beta.5' + $global:ReleasePrepCalls[2].RepoOwner | Should -Be 'microsoft' + $global:ReleasePrepCalls[2].RepoName | Should -Be 'mcp' + $global:ReleasePrepCalls[2].PROwner | Should -Be 'azure-sdk' + $global:ReleasePrepCalls[2].BaseBranch | Should -Be 'main' + $global:ReleasePrepCalls[2].Body | Should -Match '@owner-one @owner-two' + Get-Content (Join-Path $script:FixtureRoot 'servers\Azure.Mcp.Server\src\Azure.Mcp.Server.csproj') -Raw | + Should -Match '4\.0\.0-beta\.5' + Should -Invoke Get-GitHubPullRequests -Times 1 -Exactly -ParameterFilter { + $Head -eq 'azure-sdk:prepare-release-Azure.Mcp.Server-4.0.0-beta.5' -and $Base -eq 'main' -and $State -eq 'open' + } + } + + It 'leaves an existing open preparation PR and its branch unchanged' { + Mock Get-GitHubPullRequests { [PSCustomObject]@{ html_url = 'https://github.com/microsoft/mcp/pull/1' } } + Invoke-ReleasePrepPullRequest @script:PrepParameters + $global:ReleasePrepCalls.Count | Should -Be 0 + } + + It 'does not treat a GitHub error as the absence of an existing PR' { + Mock Get-GitHubPullRequests { throw 'GitHub unavailable' } + { Invoke-ReleasePrepPullRequest @script:PrepParameters } | Should -Throw '*GitHub unavailable*' + $global:ReleasePrepCalls.Count | Should -Be 0 + } + + It 'skips a version that has already been finalized' { + Set-Content (Join-Path $script:FixtureRoot 'servers\Azure.Mcp.Server\CHANGELOG.md') '## 4.0.0-beta.5 (2026-10-29)' + Invoke-ReleasePrepPullRequest @script:PrepParameters + $global:ReleasePrepCalls.Count | Should -Be 0 + Should -Invoke Get-GitHubPullRequests -Times 0 + } + + It 'skips runs with only the example entry or non-YAML files' { + $entriesPath = Join-Path $script:FixtureRoot 'servers\Azure.Mcp.Server\changelog-entries' + Remove-Item (Join-Path $entriesPath 'test-entry.yaml') + Set-Content (Join-Path $entriesPath 'username-example-brief-description.yaml') 'changes: []' + Set-Content (Join-Path $entriesPath 'README.md') 'Entry documentation.' + Invoke-ReleasePrepPullRequest @script:PrepParameters + $global:ReleasePrepCalls.Count | Should -Be 0 + Should -Invoke Get-GitHubPullRequests -Times 0 + } + + It 'rejects a mismatched changelog version' { + Set-Content (Join-Path $script:FixtureRoot 'servers\Azure.Mcp.Server\CHANGELOG.md') '## 4.0.0-beta.4 (Unreleased)' + { Invoke-ReleasePrepPullRequest @script:PrepParameters } | Should -Throw '*must match project version*' + $global:ReleasePrepCalls.Count | Should -Be 0 + } + + It 'rejects a malformed changelog date instead of silently skipping it' { + Set-Content (Join-Path $script:FixtureRoot 'servers\Azure.Mcp.Server\CHANGELOG.md') '## 4.0.0-beta.5 (pending)' + { Invoke-ReleasePrepPullRequest @script:PrepParameters } | Should -Throw '*valid release date*' + } + + It 'rejects an invalid project version' { + Set-Content (Join-Path $script:FixtureRoot 'servers\Azure.Mcp.Server\src\Azure.Mcp.Server.csproj') 'invalid' + { Invoke-ReleasePrepPullRequest @script:PrepParameters } | Should -Throw '*valid release version*' + } + + It 'refuses a dirty checkout' { + $script:StatusOutput = @(' M unrelated.txt') + { Invoke-ReleasePrepPullRequest @script:PrepParameters } | Should -Throw '*clean working tree*' + $global:ReleasePrepCalls.Count | Should -Be 0 + } + + It 'fails explicitly if git status fails' { + $script:StatusExitCode = 128 + { Invoke-ReleasePrepPullRequest @script:PrepParameters } | Should -Throw '*git exit code 128*' + } + + It 'does not push after failed compilation' { + $global:ReleasePrepCompileExitCode = 17 + { Invoke-ReleasePrepPullRequest @script:PrepParameters } | Should -Throw '*compilation failed with exit code 17*' + ($global:ReleasePrepCalls.Kind -join ',') | Should -Be 'Compile' + } + + It 'does not open a PR if compilation produces no changes' { + $script:DiffExitCode = 0 + Invoke-ReleasePrepPullRequest @script:PrepParameters + ($global:ReleasePrepCalls.Kind -join ',') | Should -Be 'Compile' + } + + It 'does not mistake a git diff failure for release changes' { + $script:DiffExitCode = 128 + { Invoke-ReleasePrepPullRequest @script:PrepParameters } | Should -Throw '*git exit code 128*' + ($global:ReleasePrepCalls.Kind -join ',') | Should -Be 'Compile' + } + + It 'does not submit a PR after a failed push' { + $global:ReleasePrepPushExitCode = 23 + { Invoke-ReleasePrepPullRequest @script:PrepParameters } | Should -Throw '*branch failed with exit code 23*' + ($global:ReleasePrepCalls.Kind -join ',') | Should -Be 'Compile,Push' + } + + It 'surfaces PR submission failures' { + $global:ReleasePrepSubmitExitCode = 29 + { Invoke-ReleasePrepPullRequest @script:PrepParameters } | Should -Throw '*PR failed with exit code 29*' + } + + AfterAll { + Remove-Variable -Scope Global -Name ReleasePrepCalls, ReleasePrepCompileExitCode, ReleasePrepPushExitCode, ReleasePrepSubmitExitCode + } +} + +Describe 'Release-preparation pipeline configuration' { + It 'disables CI and PR triggers and schedules both UTC candidates on main' { + $script:Pipeline.trigger | Should -Be 'none' + $script:Pipeline.pr | Should -Be 'none' + ($script:Pipeline.schedules.cron -join '|') | Should -Be '0 19 * * 2,4|0 20 * * 2,4' + foreach ($schedule in $script:Pipeline.schedules) { + $schedule.always | Should -BeTrue + $schedule.branches.include | Should -Contain 'main' + } + } + + It 'gates GitHub access and PR preparation on the calendar decision' { + $stage = $script:Pipeline.extends.parameters.stages[0] + $stage.condition | Should -Match "eq\(variables\['System.TeamProject'\], 'internal'\)" + $stage.condition | Should -Match "eq\(variables\['Build.Repository.Name'\], 'microsoft/mcp'\)" + $stage.condition | Should -Match "eq\(variables\['Build.SourceBranch'\], 'refs/heads/main'\)" + $stage.jobs[0].condition | Should -Match "eq\(variables\['Build.Reason'\], 'Schedule'\)" + $stage.jobs[1].condition | Should -Match "dependencies.CheckSchedule.outputs\['schedule.ShouldPrepareRelease'\], 'true'" + $stage.jobs[1].steps[-1].env.RELEASE_OWNERS | Should -Be '${{ convertToJson(parameters.ReleaseOwners) }}' + $stage.jobs[1].steps[0].fetchDepth | Should -Be 0 + } +} diff --git a/eng/scripts/helpers/ReleasePrepHelpers.ps1 b/eng/scripts/helpers/ReleasePrepHelpers.ps1 new file mode 100644 index 000000000..dd41e4a2e --- /dev/null +++ b/eng/scripts/helpers/ReleasePrepHelpers.ps1 @@ -0,0 +1,281 @@ +function Get-USFederalHoliday { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [datetime]$Date + ) + + $fixedHolidays = @( + @{ Name = "New Year's Day"; Month = 1; Day = 1 } + @{ Name = 'Independence Day'; Month = 7; Day = 4 } + @{ Name = 'Christmas Eve'; Month = 12; Day = 24 } + @{ Name = 'Christmas Day'; Month = 12; Day = 25 } + ) + + # Next year's New Year's Day can be observed on December 31 of this year. + foreach ($year in @($Date.Year, ($Date.Year + 1))) { + foreach ($holiday in $fixedHolidays) { + $actualDate = [datetime]::new($year, $holiday.Month, $holiday.Day) + if ($Date.Date -eq $actualDate) { + return $holiday.Name + } + $observedDate = switch ($actualDate.DayOfWeek) { + Saturday { $actualDate.AddDays(-1) } + Sunday { $actualDate.AddDays(1) } + default { $actualDate } + } + if ($Date.Date -eq $observedDate) { + return "$($holiday.Name) (observed)" + } + } + } + + $floatingHolidays = @( + @{ Name = 'Martin Luther King Jr. Day'; Month = 1; Weekday = 1; Week = 3 } + @{ Name = "Washington's Birthday"; Month = 2; Weekday = 1; Week = 3 } + @{ Name = 'Memorial Day'; Month = 5; Weekday = 1; Week = -1 } + @{ Name = 'Labor Day'; Month = 9; Weekday = 1; Week = 1 } + @{ Name = 'Thanksgiving Day'; Month = 11; Weekday = 4; Week = 4 } + @{ Name = 'Day after Thanksgiving'; Month = 11; Weekday = 4; Week = 4; DayOffset = 1 } + ) + foreach ($holiday in $floatingHolidays) { + $firstDay = [datetime]::new($Date.Year, $holiday.Month, 1) + if ($holiday.Week -eq -1) { + $lastDay = $firstDay.AddMonths(1).AddDays(-1) + $holidayDate = $lastDay.AddDays(-(([int]$lastDay.DayOfWeek - $holiday.Weekday + 7) % 7)) + } + else { + $holidayDate = $firstDay.AddDays( + (($holiday.Weekday - [int]$firstDay.DayOfWeek + 7) % 7) + 7 * ($holiday.Week - 1)) + } + if ($holiday.ContainsKey('DayOffset')) { + $holidayDate = $holidayDate.AddDays($holiday.DayOffset) + } + if ($Date.Date -eq $holidayDate) { + return $holiday.Name + } + } +} + +function Get-ReleasePrepSchedule { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string]$ScheduleName, + + [DateTimeOffset]$UtcNow = [DateTimeOffset]::UtcNow + ) + + $scheduledUtcHour = switch ($ScheduleName) { + 'Release preparation (PDT)' { 19 } + 'Release preparation (PST)' { 20 } + default { throw "Unrecognized release-preparation schedule: $ScheduleName" } + } + $pacificZone = [TimeZoneInfo]::FindSystemTimeZoneById('Pacific Standard Time') + $pacificTime = [TimeZoneInfo]::ConvertTime($UtcNow, $pacificZone) + $noonUtc = [TimeZoneInfo]::ConvertTimeToUtc($pacificTime.Date.AddHours(12), $pacificZone) + $holiday = Get-USFederalHoliday -Date $pacificTime.Date + $shouldRun = $false + + if ($pacificTime.DayOfWeek -notin @([DayOfWeek]::Tuesday, [DayOfWeek]::Thursday)) { + $reason = 'Skipping: the Pacific date is not a Tuesday or Thursday.' + } + elseif ($noonUtc.Hour -ne $scheduledUtcHour) { + $reason = 'Skipping: this UTC schedule is inactive for the current Pacific daylight-saving offset.' + } + elseif ($holiday) { + $reason = "Skipping US federal holiday: $holiday." + } + else { + $shouldRun = $true + $reason = 'Preparing a release for the scheduled 12 PM Pacific run.' + } + + [PSCustomObject]@{ + ShouldRun = $shouldRun + ReleaseDate = $pacificTime.ToString('yyyy-MM-dd', [Globalization.CultureInfo]::InvariantCulture) + Reason = $reason + } +} + +function Format-ReleasePrepPullRequestBody { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string]$Template, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string]$Version, + + [Parameter(Mandatory)] + [datetime]$ReleaseDate, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string[]]$ReleaseOwners, + + [Parameter(Mandatory)] + [uri]$BuildUrl + ) + + if (-not $BuildUrl.IsAbsoluteUri -or $BuildUrl.Scheme -ne 'https') { + throw 'The pipeline build URL must be an absolute HTTPS URL.' + } + $mentions = foreach ($owner in $ReleaseOwners) { + if ($owner -notmatch '^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$') { + throw "Invalid GitHub alias '$owner'. Configure usernames without the @ prefix." + } + "@$owner" + } + + $date = $ReleaseDate.ToString('yyyy-MM-dd', [Globalization.CultureInfo]::InvariantCulture) + $replacements = @{ + '`[Provide a clear, concise description of the changes]`' = + "Prepare Azure MCP Server ``$Version`` for release on $date by compiling the main and VS Code changelogs. The checked-in server version is unchanged." + '`[Add additional context, screenshots, or information that helps reviewers]`' = + "$($mentions -join ' '): please review the release notes, approve and merge this PR after required checks pass, and then start the ``mcp - Azure.Mcp.Server`` release pipeline with ``PublishTarget=public`` using the merged commit. This preparation job does not merge the PR or start a release.`n`nPreparation pipeline: [view run]($BuildUrl)." + '`[Link to the GitHub issue this PR addresses]`' = + 'Not applicable: scheduled release preparation.' + } + $body = $Template + foreach ($placeholder in $replacements.Keys) { + if (-not $body.Contains($placeholder)) { + throw "The PR template is missing the expected placeholder: $placeholder" + } + $body = $body.Replace($placeholder, $replacements[$placeholder]) + } + + $compileCommand = ".\eng\scripts\Compile-Changelog.ps1 -ChangelogPath 'servers\Azure.Mcp.Server\CHANGELOG.md'" + $body.TrimEnd() + @" + + +## Validation +- ``$compileCommand``: passed, including changelog entry validation and VS Code changelog synchronization. +- Builds and tests: not run (release-note-only changes). Required PR checks must pass before merge. + +## Invoking Livetests + +Copilot submitted PRs are not trustworthy by default. Users with ``write`` access to the repo need to validate the contents of this PR before leaving a comment with the text ``/azp run mcp - pullrequest - live``. This will trigger the necessary livetest workflows to complete required validation. +"@ +} + +function Invoke-ReleasePrepPullRequest { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$RepositoryRoot, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string[]]$ReleaseOwners, + + [Parameter(Mandatory)] + [datetime]$ReleaseDate, + + [Parameter(Mandatory)] + [uri]$BuildUrl, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string]$GitHubToken, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string]$PushToken + ) + + Set-StrictMode -Version Latest + $ErrorActionPreference = 'Stop' + Push-Location $RepositoryRoot + try { + $uncommittedChanges = @(git status --porcelain) + if ($LASTEXITCODE -ne 0) { + throw "Unable to check the working tree (git exit code $LASTEXITCODE)." + } + if ($uncommittedChanges.Count -gt 0) { + throw 'Release preparation requires a clean working tree.' + } + + $serverPath = Join-Path $RepositoryRoot 'servers\Azure.Mcp.Server' + [xml]$project = Get-Content (Join-Path $serverPath 'src\Azure.Mcp.Server.csproj') -Raw + $versionNode = $project.SelectSingleNode('/Project/PropertyGroup/Version') + if ($null -eq $versionNode -or $null -eq [AzureEngSemanticVersion]::ParseVersionString($versionNode.InnerText)) { + throw 'The Azure MCP Server project must contain a valid release version.' + } + $version = $versionNode.InnerText + $changelogPath = Join-Path $serverPath 'CHANGELOG.md' + $changelog = Get-Content $changelogPath -Raw + $heading = [regex]::Match($changelog, '(?m)^## (?\S+) \((?[^)\r\n]+)\)[ \t]*\r?$') + if (-not $heading.Success -or $heading.Groups['version'].Value -ne $version) { + throw "The latest changelog heading must match project version '$version'. Merge the post-release version increment before preparing another release." + } + if ($heading.Groups['status'].Value -ne 'Unreleased') { + $releasedDate = [datetime]::MinValue + if (-not [datetime]::TryParseExact( + $heading.Groups['status'].Value, 'yyyy-MM-dd', [Globalization.CultureInfo]::InvariantCulture, + [Globalization.DateTimeStyles]::None, [ref]$releasedDate)) { + throw 'The latest changelog heading must contain Unreleased or a valid release date.' + } + LogInfo "Version $version is already finalized. Waiting for the post-release version increment." + return + } + + $entriesPath = Join-Path $serverPath 'changelog-entries' + $entries = @(Get-ChildItem $entriesPath -File | Where-Object { + $_.Extension -in @('.yaml', '.yml') -and $_.BaseName -ne 'username-example-brief-description' + }) + if ($entries.Count -eq 0) { + LogInfo 'No changelog entries are available. Skipping release preparation.' + return + } + + $body = Format-ReleasePrepPullRequestBody ` + -Template (Get-Content (Join-Path $RepositoryRoot '.github\PULL_REQUEST_TEMPLATE.md') -Raw) ` + -Version $version -ReleaseDate $ReleaseDate -ReleaseOwners $ReleaseOwners -BuildUrl $BuildUrl + $branch = "prepare-release-Azure.Mcp.Server-$version" + $existingPullRequests = @(Get-GitHubPullRequests -RepoOwner microsoft -RepoName mcp ` + -Head "azure-sdk:$branch" -Base main -State open -AuthToken $GitHubToken | ForEach-Object { $_ }) + if ($existingPullRequests.Count -gt 0) { + LogInfo "Release-preparation PR already exists: $($existingPullRequests[0].html_url). Leaving it unchanged." + return + } + + $global:LASTEXITCODE = 0 + & (Join-Path $RepositoryRoot 'eng\scripts\Compile-Changelog.ps1') ` + -ChangelogPath 'servers\Azure.Mcp.Server\CHANGELOG.md' + if ($LASTEXITCODE -ne 0) { + throw "Changelog compilation failed with exit code $LASTEXITCODE." + } + + git diff --quiet + if ($LASTEXITCODE -eq 0) { + LogInfo 'Changelog compilation produced no changes. Skipping PR creation.' + return + } + if ($LASTEXITCODE -ne 1) { + throw "Unable to check changelog changes (git exit code $LASTEXITCODE)." + } + + $title = "Prepare Azure MCP Server $version release" + $global:LASTEXITCODE = 0 + & (Join-Path $RepositoryRoot 'eng\common\scripts\git-branch-push.ps1') ` + -PRBranchName $branch -CommitMsg $title ` + -GitUrl "https://x-access-token:$PushToken@github.com/azure-sdk/mcp.git" + if ($LASTEXITCODE -ne 0) { + throw "Pushing the release-preparation branch failed with exit code $LASTEXITCODE." + } + + & (Join-Path $RepositoryRoot 'eng\common\scripts\Submit-PullRequest.ps1') ` + -RepoOwner microsoft -RepoName mcp -BaseBranch main -PROwner azure-sdk -PRBranch $branch ` + -AuthToken $GitHubToken -PRTitle $title -PRBody $body -MaintainerCanModify $false + if ($LASTEXITCODE -ne 0) { + throw "Submitting the release-preparation PR failed with exit code $LASTEXITCODE." + } + } + finally { + Pop-Location + } +} diff --git a/servers/Azure.Mcp.Server/changelog-entries/scheduled-release-preparation.yaml b/servers/Azure.Mcp.Server/changelog-entries/scheduled-release-preparation.yaml new file mode 100644 index 000000000..e6e3c1ac1 --- /dev/null +++ b/servers/Azure.Mcp.Server/changelog-entries/scheduled-release-preparation.yaml @@ -0,0 +1,3 @@ +changes: + - section: "Other Changes" + description: "Added scheduled Azure MCP release-preparation pull requests at noon Pacific on Tuesdays and Thursdays outside Microsoft US holidays, with configurable release-owner mentions."