From 9b2096c97a9bf2638c48373a897ca1c0ba4acf08 Mon Sep 17 00:00:00 2001 From: Adam Boniecki <20281641+abonie@users.noreply.github.com> Date: Fri, 11 Sep 2026 11:18:55 +0200 Subject: [PATCH 1/6] Initial script for creating backport branch --- .../scripts/Get-VSBackportBranchPoint.ps1 | 461 ++++++++++++++++++ 1 file changed, 461 insertions(+) create mode 100644 eng/release/scripts/Get-VSBackportBranchPoint.ps1 diff --git a/eng/release/scripts/Get-VSBackportBranchPoint.ps1 b/eng/release/scripts/Get-VSBackportBranchPoint.ps1 new file mode 100644 index 00000000000..3f05fc28d0e --- /dev/null +++ b/eng/release/scripts/Get-VSBackportBranchPoint.ps1 @@ -0,0 +1,461 @@ +<# +.SYNOPSIS + Finds the F# source commit corresponding to the latest F# insertion on a Visual Studio branch. + +.DESCRIPTION + Refreshes the Visual Studio branch's configured upstream, finds the first-parent commit that + introduced its current F# payload, and queries Azure DevOps pipeline definition 499 for the + payload build's source commit. + + By default, prints the command and pipeline changes needed to create a backport branch. When + -Execute is specified, creates and checks out the branch and updates azure-pipelines.yml. + Requires the Azure CLI to be installed and authenticated. + +.PARAMETER VSBranch + The Visual Studio branch whose latest F# insertion should be located. + +.PARAMETER VSRepoPath + The path to a local clone of the Visual Studio repository. + +.PARAMETER FSharpRepoPath + The path to a local clone of the dotnet/fsharp repository. + +.PARAMETER NewBranchName + The name of the F# backport branch to create. + +.PARAMETER Execute + Creates the branch and updates azure-pipelines.yml. Without this switch, no changes are made. + +.EXAMPLE + .\Get-VSBackportBranchPoint.ps1 ` + -VSBranch rel/insiders ` + -VSRepoPath Q:\source\VS ` + -FSharpRepoPath Q:\source\fsharp ` + -NewBranchName release/dev18.10 + +.EXAMPLE + .\Get-VSBackportBranchPoint.ps1 ` + -VSBranch rel/insiders ` + -VSRepoPath Q:\source\VS ` + -FSharpRepoPath Q:\source\fsharp ` + -NewBranchName release/dev18.10 ` + -Execute +#> + +[CmdletBinding(PositionalBinding = $false)] +param ( + [Parameter(Mandatory = $true)] + [string]$VSBranch, + + [Parameter(Mandatory = $true)] + [string]$VSRepoPath, + + [Parameter(Mandatory = $true)] + [string]$FSharpRepoPath, + + [Parameter(Mandatory = $true)] + [string]$NewBranchName, + + [switch]$Execute +) + +Set-StrictMode -Version 2.0 +$ErrorActionPreference = "Stop" + +function Invoke-Git { + param ( + [Parameter(Mandatory = $true)] + [string]$RepositoryPath, + + [Parameter(Mandatory = $true)] + [string[]]$Arguments + ) + + $output = & git -C $RepositoryPath @Arguments 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "git -C `"$RepositoryPath`" $($Arguments -join ' ') failed:`n$($output -join "`n")" + } + + return $output +} + +function Resolve-GitRepository { + param ( + [Parameter(Mandatory = $true)] + [string]$Path, + + [Parameter(Mandatory = $true)] + [string]$Name + ) + + if (-not (Test-Path -LiteralPath $Path -PathType Container)) { + throw "$Name repository path does not exist or is not a directory: $Path" + } + + $repositoryRoot = Invoke-Git -RepositoryPath $Path -Arguments @("rev-parse", "--show-toplevel") | + Select-Object -First 1 + + return [IO.Path]::GetFullPath($repositoryRoot) +} + +function Test-GitRef { + param ( + [Parameter(Mandatory = $true)] + [string]$RepositoryPath, + + [Parameter(Mandatory = $true)] + [string]$Ref + ) + + & git -C $RepositoryPath rev-parse --verify --quiet "$Ref^{commit}" 2>$null | Out-Null + return $LASTEXITCODE -eq 0 +} + +function Test-GitAncestor { + param ( + [Parameter(Mandatory = $true)] + [string]$RepositoryPath, + + [Parameter(Mandatory = $true)] + [string]$Ancestor, + + [Parameter(Mandatory = $true)] + [string]$Descendant + ) + + & git -C $RepositoryPath merge-base --is-ancestor $Ancestor $Descendant 2>$null + return $LASTEXITCODE -eq 0 +} + +function Resolve-VisualStudioBranch { + param ( + [Parameter(Mandatory = $true)] + [string]$RepositoryPath, + + [Parameter(Mandatory = $true)] + [string]$Branch + ) + + $localBranchRef = "refs/heads/$Branch" + if (-not (Test-GitRef -RepositoryPath $RepositoryPath -Ref $localBranchRef)) { + if (-not (Test-GitRef -RepositoryPath $RepositoryPath -Ref $Branch)) { + throw "Visual Studio branch or ref '$Branch' was not found." + } + + Write-Warning "Using Visual Studio ref '$Branch' without fetching because it is not a local branch with a configured upstream." + return $Branch + } + + $upstream = Invoke-Git -RepositoryPath $RepositoryPath -Arguments @( + "for-each-ref", + "--format=%(upstream:short)", + $localBranchRef + ) | Select-Object -First 1 + + if (-not $upstream) { + Write-Warning "Using local Visual Studio branch '$Branch' without fetching because it has no configured upstream." + return $Branch + } + + $remote = Invoke-Git -RepositoryPath $RepositoryPath -Arguments @( + "for-each-ref", + "--format=%(upstream:remotename)", + $localBranchRef + ) | Select-Object -First 1 + + $null = Invoke-Git -RepositoryPath $RepositoryPath -Arguments @( + "fetch", + $remote, + "--quiet" + ) + + return $upstream +} + +function Resolve-FSharpBranch { + param ( + [Parameter(Mandatory = $true)] + [string]$RepositoryPath, + + [Parameter(Mandatory = $true)] + [string]$Branch, + + [Parameter(Mandatory = $true)] + [string]$Commit + ) + + $candidates = @( + "upstream/$Branch", + "origin/$Branch", + $Branch + ) + + foreach ($candidate in $candidates) { + if ((Test-GitRef -RepositoryPath $RepositoryPath -Ref $candidate) -and + (Test-GitAncestor -RepositoryPath $RepositoryPath -Ancestor $Commit -Descendant $candidate)) { + return $candidate + } + } + + throw "F# source commit '$Commit' was not found on a local, upstream, or origin ref for branch '$Branch'. Fetch the F# repository and try again." +} + +function ConvertTo-PowerShellLiteral { + param ( + [Parameter(Mandatory = $true)] + [string]$Value + ) + + return "'$($Value.Replace("'", "''"))'" +} + +function Set-PipelineVariable { + param ( + [Parameter(Mandatory = $true)] + [string]$Content, + + [Parameter(Mandatory = $true)] + [string]$Name, + + [Parameter(Mandatory = $true)] + [string]$Value + ) + + $escapedName = [regex]::Escape($Name) + $pattern = "(?m)(^\s*-\s+name:\s*$escapedName\s*\r?\n\s*value:\s*)[^\r\n]+" + $matches = [regex]::Matches($Content, $pattern) + + if ($matches.Count -ne 1) { + throw "Expected one '$Name' variable in azure-pipelines.yml; found $($matches.Count)." + } + + return [regex]::Replace( + $Content, + $pattern, + { param($match) $match.Groups[1].Value + $Value } + ) +} + +function Get-AzureDevOpsToken { + $token = & az account get-access-token ` + --resource "499b84ac-1321-427f-aa17-267ca6975798" ` + --query accessToken ` + -o tsv 2>&1 + + if ($LASTEXITCODE -ne 0 -or -not $token) { + throw "Unable to acquire an Azure DevOps access token. Run 'az login' and try again.`n$($token -join "`n")" + } + + return $token | Select-Object -First 1 +} + +function Get-FSharpBuild { + param ( + [Parameter(Mandatory = $true)] + [string]$BuildNumber, + + [Parameter(Mandatory = $true)] + [string]$SourceBranch + ) + + $definitionId = 499 + $escapedBuildNumber = [Uri]::EscapeDataString($BuildNumber) + $escapedSourceBranch = [Uri]::EscapeDataString("refs/heads/$SourceBranch") + $url = "https://dev.azure.com/dnceng/internal/_apis/build/builds" + + "?definitions=$definitionId" + + "&buildNumber=$escapedBuildNumber" + + "&branchName=$escapedSourceBranch" + + "&api-version=7.1" + $webClient = [System.Net.WebClient]::new() + + try { + $webClient.Headers.Add("Authorization", "Bearer $(Get-AzureDevOpsToken)") + $response = $webClient.DownloadString($url) | ConvertFrom-Json + } + catch { + throw "Failed to query fsharp-ci build '$BuildNumber' for branch '$SourceBranch' from Azure DevOps: $($_.Exception.Message)" + } + finally { + $webClient.Dispose() + } + + $builds = @($response.value) + if ($builds.Count -ne 1) { + throw "Expected one fsharp-ci build numbered '$BuildNumber' for branch '$SourceBranch'; found $($builds.Count)." + } + + return $builds[0] +} + +$vsRepository = Resolve-GitRepository -Path $VSRepoPath -Name "Visual Studio" +$fsharpRepository = Resolve-GitRepository -Path $FSharpRepoPath -Name "F#" + +$null = Invoke-Git -RepositoryPath $fsharpRepository -Arguments @( + "check-ref-format", + "--branch", + $NewBranchName +) + +if (Test-GitRef -RepositoryPath $fsharpRepository -Ref "refs/heads/$NewBranchName") { + throw "Local F# branch '$NewBranchName' already exists." +} + +if ($NewBranchName -notmatch "^(feature|release)/") { + Write-Warning "Branch '$NewBranchName' is not covered by the pipeline's feature/* or release/* CI triggers." +} + +if ($Execute) { + $trackedChanges = Invoke-Git -RepositoryPath $fsharpRepository -Arguments @( + "status", + "--porcelain", + "--untracked-files=no" + ) + + if ($trackedChanges) { + throw "The F# repository has tracked working-tree changes. Commit or stash them before using -Execute." + } +} + +$vsBranchRef = Resolve-VisualStudioBranch -RepositoryPath $vsRepository -Branch $VSBranch + +$componentsPath = ".corext/Configs/components.json" +$branchComponentsJson = (Invoke-Git -RepositoryPath $vsRepository -Arguments @( + "show", + "${vsBranchRef}:$componentsPath" +)) -join "`n" +$branchComponents = $branchComponentsJson | ConvertFrom-Json +$fsharpComponent = $branchComponents.Components."Microsoft.FSharp" + +if (-not $fsharpComponent) { + throw "The F# component was not found in $componentsPath on Visual Studio branch '$VSBranch'." +} + +$insertionCommit = Invoke-Git -RepositoryPath $vsRepository -Arguments @( + "log", + $vsBranchRef, + "--first-parent", + "-n", + "1", + "--format=%H", + "-S$($fsharpComponent.url)", + "--", + $componentsPath +) + +if (-not $insertionCommit) { + throw "No F# insertion was found on Visual Studio branch '$VSBranch'." +} + +$insertionCommit = $insertionCommit | Select-Object -First 1 +$insertionDate = (Invoke-Git -RepositoryPath $vsRepository -Arguments @( + "show", + "-s", + "--format=%cI", + $insertionCommit +)) | Select-Object -First 1 +$insertionSubject = (Invoke-Git -RepositoryPath $vsRepository -Arguments @( + "show", + "-s", + "--format=%s", + $insertionCommit +)) | Select-Object -First 1 + +$buildNumberMatch = [regex]::Match( + $fsharpComponent.url, + "/dotnet-fsharp/(?.+)/(?[^/;]+);", + [System.Text.RegularExpressions.RegexOptions]::IgnoreCase +) +if (-not $buildNumberMatch.Success) { + throw "Unable to extract the F# source branch and build number from component URL '$($fsharpComponent.url)'." +} + +$sourceBranch = [Uri]::UnescapeDataString($buildNumberMatch.Groups["sourceBranch"].Value) +$buildNumber = $buildNumberMatch.Groups["buildNumber"].Value +$build = Get-FSharpBuild -BuildNumber $buildNumber -SourceBranch $sourceBranch +$branchPoint = $build.sourceVersion + +if ($branchPoint -notmatch "^[0-9a-f]{40}$") { + throw "Build '$buildNumber' returned invalid source version '$branchPoint'." +} + +$fsharpBranchRef = Resolve-FSharpBranch ` + -RepositoryPath $fsharpRepository ` + -Branch $sourceBranch ` + -Commit $branchPoint + +$branchPointDate = (Invoke-Git -RepositoryPath $fsharpRepository -Arguments @( + "show", + "-s", + "--format=%cI", + $branchPoint +)) | Select-Object -First 1 +$branchPointSubject = (Invoke-Git -RepositoryPath $fsharpRepository -Arguments @( + "show", + "-s", + "--format=%s", + $branchPoint +)) | Select-Object -First 1 +$componentVersionProperty = $fsharpComponent.PSObject.Properties["version"] +$fsharpRepoLiteral = ConvertTo-PowerShellLiteral -Value $fsharpRepository +$newBranchLiteral = ConvertTo-PowerShellLiteral -Value $NewBranchName +$branchPointLiteral = ConvertTo-PowerShellLiteral -Value $branchPoint +$gitCommand = "git -C $fsharpRepoLiteral switch -c $newBranchLiteral $branchPointLiteral" +$action = "Preview" + +if ($Execute) { + $pipelinePath = Join-Path $fsharpRepository "azure-pipelines.yml" + # Validate both mappings before creating the branch; edit the checked-out file afterward to preserve its formatting. + $branchPointPipelineContent = (Invoke-Git -RepositoryPath $fsharpRepository -Arguments @( + "show", + "${branchPoint}:azure-pipelines.yml" + )) -join "`n" + $branchPointPipelineContent = Set-PipelineVariable ` + -Content $branchPointPipelineContent ` + -Name "FSharpReleaseBranchName" ` + -Value $NewBranchName + $null = Set-PipelineVariable ` + -Content $branchPointPipelineContent ` + -Name "VSInsertionTargetBranchName" ` + -Value $VSBranch + + $null = Invoke-Git -RepositoryPath $fsharpRepository -Arguments @( + "switch", + "-c", + $NewBranchName, + $branchPoint + ) + + $pipelineContent = [IO.File]::ReadAllText($pipelinePath) + $pipelineContent = Set-PipelineVariable ` + -Content $pipelineContent ` + -Name "FSharpReleaseBranchName" ` + -Value $NewBranchName + $pipelineContent = Set-PipelineVariable ` + -Content $pipelineContent ` + -Name "VSInsertionTargetBranchName" ` + -Value $VSBranch + [IO.File]::WriteAllText($pipelinePath, $pipelineContent) + $action = "Executed" +} + +[PSCustomObject]@{ + Action = $action + VSBranch = $VSBranch + VSResolvedBranchRef = $vsBranchRef + VSInsertionCommit = $insertionCommit + VSInsertionDate = $insertionDate + VSInsertionSubject = $insertionSubject + FSharpComponentVersion = if ($componentVersionProperty) { $componentVersionProperty.Value } else { $null } + FSharpComponentUrl = $fsharpComponent.url + FSharpSourceBranch = $sourceBranch + FSharpResolvedBranchRef = $fsharpBranchRef + FSharpBuildNumber = $buildNumber + FSharpBuildId = $build.id + FSharpBranchPoint = $branchPoint + FSharpBranchPointDate = $branchPointDate + FSharpBranchPointSubject = $branchPointSubject + NewBranchName = $NewBranchName + GitCommand = $gitCommand + PipelineFSharpBranch = $NewBranchName + PipelineVSBranch = $VSBranch +} From ad16d786c1d4f88f1bdaebd2d69a51600c5a2125 Mon Sep 17 00:00:00 2001 From: Adam Boniecki <20281641+abonie@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:09:40 +0200 Subject: [PATCH 2/6] Add VS backport utility and heuristic minor versioning Support temporary authenticated VS clones and caller-pinned backport minors. Compute official VS minor versions from the Patch Tuesday heuristic with reproducible timestamps and evaluation-time validation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 14fe971f-b624-4dbb-b9af-7683df5c1542 --- azure-pipelines.yml | 6 +- eng/Build.ps1 | 6 + eng/VSMinorVersion.props | 49 ++ eng/Versions.props | 6 + eng/release/README.md | 103 ++++ eng/release/VSVersion.proj | 6 + .../Get-VSBackportBranchPoint.Tests.ps1 | 286 +++++++++ .../scripts/Get-VSBackportBranchPoint.ps1 | 542 ++++++++++++------ eng/release/scripts/VSMinorVersion.Tests.ps1 | 178 ++++++ 9 files changed, 1004 insertions(+), 178 deletions(-) create mode 100644 eng/VSMinorVersion.props create mode 100644 eng/release/README.md create mode 100644 eng/release/VSVersion.proj create mode 100644 eng/release/scripts/Get-VSBackportBranchPoint.Tests.ps1 create mode 100644 eng/release/scripts/VSMinorVersion.Tests.ps1 diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 1517ff30b68..920cbeec59a 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -25,6 +25,8 @@ trigger: - TESTGUIDE.md variables: + - name: VSBuildTimestampUtc + value: $[ format('{0:yyyy-MM-ddTHH:mm:ss.fffffffzzz}', pipeline.startTime) ] # Release branch for F# # The future "product" gets developed in main, so should be main in main. # When servicing branch gets created, this should maintain the mapping between F# servicing and VS servicing branches @@ -105,7 +107,7 @@ extends: platforms: - name: 'Managed' container: 'mcr.microsoft.com/dotnet-buildtools/prereqs:centos-stream9' - buildArguments: '--source-build' + buildArguments: '--source-build /p:VSBuildTimestampUtc=$(VSBuildTimestampUtc)' enableTelemetry: true helixRepo: dotnet/fsharp jobs: @@ -142,6 +144,7 @@ extends: /p:TeamName=$(_TeamName) /p:DotNetPublishUsingPipelines=$(_PublishUsingPipelines) /p:OfficialBuildId=$(BUILD.BUILDNUMBER) + /p:VSBuildTimestampUtc=$(VSBuildTimestampUtc) /p:VisualStudioDropName=$(VisualStudioDropName) /p:GenerateSbom=true env: @@ -232,4 +235,3 @@ extends: insertTeamEmail: fsharpteam@microsoft.com insertTeamName: 'F#' completeInsertion: 'auto' - diff --git a/eng/Build.ps1 b/eng/Build.ps1 index 3b34d6e9a94..21764e9f2c3 100644 --- a/eng/Build.ps1 +++ b/eng/Build.ps1 @@ -86,6 +86,12 @@ $ErrorActionPreference = "Stop" if ($ci -and -not $PSBoundParameters.ContainsKey('msbuildMultiThreaded')) { $msbuildMultiThreaded = $false } + +# Capture once so restore, build and pack cannot straddle a scheduled version transition. +if ($official -and -not $env:VSBuildTimestampUtc -and -not ($properties -match '^[-/]p:VSBuildTimestampUtc=')) { + $properties += "/p:VSBuildTimestampUtc=$([DateTime]::UtcNow.ToString('o', [Globalization.CultureInfo]::InvariantCulture))" +} + $BuildCategory = "" $BuildMessage = "" diff --git a/eng/VSMinorVersion.props b/eng/VSMinorVersion.props new file mode 100644 index 00000000000..e2acc39a1f0 --- /dev/null +++ b/eng/VSMinorVersion.props @@ -0,0 +1,49 @@ + + + + false + + + <_VSOffsetTimestamp>$([System.Text.RegularExpressions.Regex]::Replace('$(VSBuildTimestampUtc)', 'Z$', '+00:00')) + <_VSBuildTicks>$([System.DateTime]::ParseExact('$(_VSOffsetTimestamp)', 'yyyy-MM-ddTHH:mm:ss.FFFFFFFzzz', $([System.Globalization.CultureInfo]::GetCultureInfo(''))).ToUniversalTime().Ticks) + + <_VSMinorBaseYear>2026 + <_VSMinorBaseMonth>10 + <_VSMinorBase>13 + + <_VSNominalMonth>$([System.DateTime]::new($(_VSBuildTicks)).AddDays(10).ToString('yyyy-MM-01', $([System.Globalization.CultureInfo]::GetCultureInfo('')))) + <_VSMonthStart>$([System.DateTime]::ParseExact('$(_VSNominalMonth)', 'yyyy-MM-dd', $([System.Globalization.CultureInfo]::GetCultureInfo('')))) + <_VSFirstWeekday>$([System.Convert]::ToInt32($([System.DateTime]::Parse('$(_VSMonthStart)').DayOfWeek))) + <_VSPatchTuesdayOffset>$([MSBuild]::Add(7, $([MSBuild]::Modulo($([MSBuild]::Subtract(9, $(_VSFirstWeekday))), 7)))) + <_VSNominalSnap>$([System.DateTime]::Parse('$(_VSMonthStart)').AddDays($(_VSPatchTuesdayOffset)).AddDays(-11).AddHours(12)) + + <_VSSnapYear>$([System.DateTime]::Parse('$(_VSNominalSnap)').Year) + <_VSMarchWeekday>$([System.Convert]::ToInt32($([System.DateTime]::new($(_VSSnapYear), 3, 1).DayOfWeek))) + <_VSNovemberWeekday>$([System.Convert]::ToInt32($([System.DateTime]::new($(_VSSnapYear), 11, 1).DayOfWeek))) + <_VSDaylightStart>$([System.DateTime]::new($(_VSSnapYear), 3, 1).AddDays($([MSBuild]::Add(7, $([MSBuild]::Modulo($([MSBuild]::Subtract(7, $(_VSMarchWeekday))), 7))))).Ticks) + <_VSDaylightEnd>$([System.DateTime]::new($(_VSSnapYear), 11, 1).AddDays($([MSBuild]::Modulo($([MSBuild]::Subtract(7, $(_VSNovemberWeekday))), 7))).Ticks) + <_VSPacificOffset>8 + <_VSPacificOffset Condition="$([System.DateTime]::Parse('$(_VSNominalSnap)').Ticks) >= $(_VSDaylightStart) and $([System.DateTime]::Parse('$(_VSNominalSnap)').Ticks) < $(_VSDaylightEnd)">7 + <_VSNominalTicks>$([System.DateTime]::Parse('$(_VSNominalSnap)').AddHours($(_VSPacificOffset)).AddHours(24).Ticks) + <_VSMonthDifference>$([MSBuild]::Add($([MSBuild]::Multiply($([MSBuild]::Subtract($([System.DateTime]::Parse('$(_VSMonthStart)').Year), $(_VSMinorBaseYear))), 12)), $([MSBuild]::Subtract($([System.DateTime]::Parse('$(_VSMonthStart)').Month), $(_VSMinorBaseMonth))))) + + <_VSMonthDifference Condition="$([System.Int64]::Parse('$(_VSBuildTicks)').CompareTo($([System.Int64]::Parse('$(_VSNominalTicks)')))) < 0">$([MSBuild]::Subtract($(_VSMonthDifference), 1)) + <_VSSelectedMinor>$([MSBuild]::Add($(_VSMinorBase), $(_VSMonthDifference))) + <_VSValidationError /> + <_VSValidationError Condition="'$(VSMajorVersion)' != '18'">The VS minor heuristic covers major 18 only. Update the version-number calibration. + <_VSValidationError Condition="$(_VSSelectedMinor) < 0">The computed VS minor is below 0. Check VSBuildTimestampUtc and the version-number calibration. + <_VSValidationError Condition="$(_VSSelectedMinor) > 65534">The computed VS minor exceeds 65534. Check VSBuildTimestampUtc and the version-number calibration. + + <_VSValidationResult Condition="'$(_VSValidationError)' != ''">$([System.Int32]::Parse('$(_VSValidationError)')) + $(_VSSelectedMinor) + true + $(MSBuildWarningsNotAsErrors);FSVS1001 + + + + + + diff --git a/eng/Versions.props b/eng/Versions.props index 7bcf4b16972..6fec6fdbc1f 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -63,6 +63,12 @@ 18 8 + true + + + + + $(VSMajorVersion).0 $(VSMajorVersion).$(VSMinorVersion).0 $(VSAssemblyVersionPrefix).0 diff --git a/eng/release/README.md b/eng/release/README.md new file mode 100644 index 00000000000..d4069051de9 --- /dev/null +++ b/eng/release/README.md @@ -0,0 +1,103 @@ +# Visual Studio backport branches + +`scripts\Get-VSBackportBranchPoint.ps1` finds the F# source commit that produced +the payload currently inserted into a VS branch. It can create an F# backport +branch, configure its insertion target, and pin its VS minor version. + +## Prerequisites + +- Git, PowerShell, and an authenticated Azure CLI (`az login`). +- Permission to read DevDiv's VS repository and dnceng/internal F# build metadata. + Signing in alone does not grant either permission. +- A local F# clone containing the source branch and commit being serviced. + +## Preview and create a branch + +Supply the minor version the target VS branch will release. It is not inferred +from the branch name or from the version of the currently inserted F# payload. + +```powershell +.\eng\release\scripts\Get-VSBackportBranchPoint.ps1 ` + -VSBranch rel/insiders ` + -FSharpRepoPath Q:\source\fsharp ` + -NewBranchName release/dev18.11 ` + -VSMinorVersion 11 +``` + +The default is a preview. Add `-Execute` to create and check out the F# branch, +update `FSharpReleaseBranchName` and `VSInsertionTargetBranchName` in +`azure-pipelines.yml`, and fix `VSMinorVersion` in `eng\Versions.props`. +The script does not commit, push, or insert anything into VS. Execution requires +a clean tracked F# worktree and must not overwrite untracked files. + +Optionally supply `-VSRepoPath Q:\source\VS`. Without it, the script uses an +authenticated shallow VS clone in an owned `.vs-backport-` directory beneath +the invocation's working directory, and removes it when finished, including on +failure. Initial depth is 100 commits; it deepens by 100 commits up to ten times. +If the history limit is reached, supply a clone with sufficient history. Preview +can fetch refs and use temporary disk space but does not edit the F# worktree. + +The caller is responsible for choosing the correct release minor. Two VS release +branches can legitimately target the same minor. A backport has +`UseVSScheduledMinorVersion=false`, so later builds retain the supplied minor even +when they receive a build timestamp. + +## Main's automatic minor version + +`eng\VSMinorVersion.props` selects `VSMinorVersion` during MSBuild evaluation when +`UseVSScheduledMinorVersion=true` and `VSBuildTimestampUtc` is supplied. +Official builds supply one timestamp across restore, build, and pack. Local +builds without an explicit timestamp retain the literal minor in +`eng\Versions.props`. + +The nominal main-to-insiders snap is **Patch Tuesday (the second Tuesday of each +month) minus 11 calendar days**, at noon Pacific. The minor changes **24 elapsed +hours later**. All snap dates are calculated; there is no release schedule table. +Pacific daylight-saving time is taken into account when converting to UTC. + +To reproduce a selection without compiling: + +```powershell +dotnet msbuild eng\release\VSVersion.proj -t:PrintVSVersion ` + -p:VSBuildTimestampUtc=2026-09-11T12:00:00Z +``` + +This selects minor 12. Use the same property on a build command to override the +timestamp. Timestamps must include `Z` or a numeric UTC offset. Version selection +needs no network access. + +Minor numbering uses a single calibration: October 2026's Patch Tuesday cycle +maps to minor 13. Each earlier/later month decrements/increments that number once. +This reference establishes version numbers, not snap dates; it requires no +monthly updates. The snap can fall in the preceding calendar month. +Automatic selections emit advisory warning `FSVS1001`, including when general +MSBuild warnings are treated as errors, because the heuristic cannot confirm the +actual state of the VS branches. + +Input validation also runs during evaluation-only queries such as `-getProperty`: +missing offsets, unsupported majors, and out-of-range minors fail rather than +falling back to a literal version. These queries do not execute warning targets; +read `VSMinorVersionIsHeuristic` to distinguish an automatic estimate (`true`) +from the local fallback or a fixed backport minor (`false`). + +Estimates do not account for holidays or rescheduled snaps and may run ahead of +or behind actual VS development. Pacific conversion +uses the US DST rule: second Sunday in March through first Sunday in November; +update it if that rule changes. The calibration covers VS major 18; changing the +major or minor numbering convention requires updating `_VSMinorBaseYear`, +`_VSMinorBaseMonth`, and `_VSMinorBase` in `eng\VSMinorVersion.props`. Computed +minors outside 0..65534 are rejected. + +For reproducibility, retain both the source revision (including its calibration) +and the build timestamp. Do not add raw private schedule documents to the repo. + +## Tests + +Run the PowerShell tests using the existing Pester installation: + +```powershell +Invoke-Pester eng\release\scripts +``` + +The tests use local Git fixtures and MSBuild evaluation; no VS insertion is +performed. diff --git a/eng/release/VSVersion.proj b/eng/release/VSVersion.proj new file mode 100644 index 00000000000..b58deecf130 --- /dev/null +++ b/eng/release/VSVersion.proj @@ -0,0 +1,6 @@ + + + + + + diff --git a/eng/release/scripts/Get-VSBackportBranchPoint.Tests.ps1 b/eng/release/scripts/Get-VSBackportBranchPoint.Tests.ps1 new file mode 100644 index 00000000000..08b99292ca6 --- /dev/null +++ b/eng/release/scripts/Get-VSBackportBranchPoint.Tests.ps1 @@ -0,0 +1,286 @@ +# Compatible with the repository's Pester 3.x runner. +# Invoke-Pester .\eng\release\scripts\Get-VSBackportBranchPoint.Tests.ps1 +. "$PSScriptRoot\Get-VSBackportBranchPoint.ps1" -VSBranch fixture -FSharpRepoPath fixture -NewBranchName fixture -VSMinorVersion 0 +$script:realGit = ${function:Invoke-Git} +$script:realSetMinor = ${function:Set-FixedVSMinorVersion} + +Describe 'Fixed VS minor editing' { + $literal = @' + + + 18 + 8 + keep + + +'@ + + It 'accepts zero and the maximum component, preserving unrelated content' { + foreach ($minor in @('0', '65534')) { + $result = Set-FixedVSMinorVersion $literal $minor + $result | Should Match "$minor" + $result | Should Match 'false' + $result | Should Match '18' + $result | Should Match 'keep' + } + } + + It 'rejects invalid and fractional inputs before numeric coercion' { + foreach ($minor in @('-1', '65535', '1.5', '1e1', ' 11', 'NaN', '')) { + { Invoke-VSBackportBranchPoint -VSBranch fixture -FSharpRepoPath fixture -NewBranchName fixture -VSMinorVersion $minor } | + Should Throw 'VSMinorVersion' + } + { Invoke-VSBackportBranchPoint -VSBranch fixture -FSharpRepoPath fixture -NewBranchName fixture -VSMinorVersion 1.5 } | + Should Throw 'VSMinorVersion' + } + + It 'allows the same minor on multiple branches and preserves CRLF' { + $content = $literal.Replace("`r`n", "`n").Replace("`n", "`r`n") + $result = Set-FixedVSMinorVersion $content 8 + (Set-FixedVSMinorVersion $result 8) | Should Be $result + $result.Replace("`r`n", '') | Should Not Match "`n" + } + + It 'replaces a conditional scheduled default before its import' { + $content = $literal.Replace('keep', @' +true +'@).Replace('', ' ') + $result = Set-FixedVSMinorVersion $content 11 + $result | Should Match '11' + $result.IndexOf('false') | Should BeLessThan $result.IndexOf('8', ''), + $literal.Replace('8', '89'), + $literal.Replace('', ''), + $literal.Replace('8', '$(Computed)'), + $literal.Replace('', '') + )) { + { Set-FixedVSMinorVersion $content 11 } | Should Throw 'eng\Versions.props' + } + } + + It 'freezes the actual Versions.props when a later build timestamp is supplied' { + $eng = Join-Path $PSScriptRoot '..\..' + $versions = Set-FixedVSMinorVersion ([IO.File]::ReadAllText((Join-Path $eng 'Versions.props'))) 11 + $path = Join-Path $TestDrive 'Versions.props' + [IO.File]::WriteAllText($path, $versions) + Copy-Item (Join-Path $eng 'Version.Details.props') $TestDrive + Copy-Item (Join-Path $eng 'VSMinorVersion.props') $TestDrive + $output = & dotnet msbuild $path -nologo -t:ValidateVSScheduledMinorVersion ` + -p:VSBuildTimestampUtc=2026-12-02T19:00:00Z -getProperty:VSMinorVersion,UseVSScheduledMinorVersion,VSAssemblyVersion + $LASTEXITCODE | Should Be 0 + $properties = (($output -join "`n") | ConvertFrom-Json).Properties + $properties.VSMinorVersion | Should Be '11' + $properties.UseVSScheduledMinorVersion | Should Be 'false' + $properties.VSAssemblyVersion | Should Be '18.11.0.0' + } +} + +Describe 'Backport Git fixtures' { + BeforeEach { + $script:fixtureRoot = Join-Path (Get-Location).Path ('.vs-backport-tests-' + [Guid]::NewGuid().ToString('N')) + $script:fsRepo = Join-Path $fixtureRoot 'fsharp' + $script:vsRepo = Join-Path $fixtureRoot 'vs' + foreach ($repo in @($fsRepo, $vsRepo)) { + $null = New-Item -ItemType Directory -Path $repo -Force + $null = Invoke-Git $repo @('init', '--quiet', '--initial-branch=main') + $null = Invoke-Git $repo @('config', 'user.name', 'Fixture') + $null = Invoke-Git $repo @('config', 'user.email', 'fixture@example.invalid') + $null = Invoke-Git $repo @('config', 'core.autocrlf', 'false') + $null = Invoke-Git $repo @('config', 'commit.gpgsign', 'false') + } + $null = New-Item -ItemType Directory -Path "$fsRepo\eng" + [IO.File]::WriteAllText("$fsRepo\azure-pipelines.yml", "variables:`r`n- name: FSharpReleaseBranchName`r`n value: main`r`n- name: VSInsertionTargetBranchName`r`n value: main`r`n") + [IO.File]::WriteAllText("$fsRepo\eng\Versions.props", "`r`n `r`n 18`r`n 8`r`n `r`n`r`n") + $null = Invoke-Git $fsRepo @('add', '.') + $null = Invoke-Git $fsRepo @('commit', '--quiet', '-m', 'source') + $script:source = Invoke-Git $fsRepo @('rev-parse', 'HEAD') + $null = New-Item -ItemType Directory -Path "$vsRepo\.corext\Configs" -Force + $script:payload = 'https://example.invalid/dotnet-fsharp/main/20260911.1;payload' + [IO.File]::WriteAllText("$vsRepo\.corext\Configs\components.json", '{"Components":{"Microsoft.FSharp":{"url":"old"}}}') + $null = Invoke-Git $vsRepo @('add', '.') + $null = Invoke-Git $vsRepo @('commit', '--quiet', '-m', 'old payload') + [IO.File]::WriteAllText("$vsRepo\.corext\Configs\components.json", ('{"Components":{"Microsoft.FSharp":{"url":"' + $payload + '","version":"1"}}}')) + $null = Invoke-Git $vsRepo @('commit', '--quiet', '-am', 'insert payload') + $script:insertion = Invoke-Git $vsRepo @('rev-parse', 'HEAD') + 1..3 | ForEach-Object { $null = Invoke-Git $vsRepo @('commit', '--quiet', '--allow-empty', '-m', "later $_") } + $script:arguments = @{ VSBranch = 'refs/heads/main'; VSRepoPath = $vsRepo; FSharpRepoPath = $fsRepo; NewBranchName = 'release/fixture'; VSMinorVersion = '11' } + Mock Get-AzureDevOpsToken { 'fixture-only-token' } + Mock Get-FSharpBuild { + param($BuildNumber, $SourceBranch) + if ($BuildNumber -ne '20260911.1' -or $SourceBranch -ne 'main') { throw 'Incorrect build lookup' } + [PSCustomObject]@{ sourceVersion = $script:source; id = 42 } + } + } + + AfterEach { + Remove-Item -LiteralPath $fixtureRoot -Recurse -Force + } + + It 'previews the exact source and edits without changing either checkout' { + $vsHead = Invoke-Git $vsRepo @('rev-parse', 'HEAD') + $result = Invoke-VSBackportBranchPoint @arguments + $result.Action | Should Be 'Preview' + $result.VSBranch | Should Be 'main' + $result.VSSnapshot | Should Be $vsHead + $result.VSInsertionCommit | Should Be $insertion + $result.FSharpBranchPoint | Should Be $source + $result.VSMinorVersion | Should Be 11 + $result.PlannedEdits.Count | Should Be 2 + (Invoke-Git $fsRepo @('status', '--porcelain')) | Should BeNullOrEmpty + (Test-GitRef $fsRepo 'refs/heads/release/fixture') | Should Be $false + (Invoke-Git $vsRepo @('rev-parse', 'HEAD')) | Should Be $vsHead + } + + It 'executes only in the fixture, preserving newlines and leaving uncommitted edits' { + $result = Invoke-VSBackportBranchPoint @arguments -Execute + $result.Action | Should Be 'Executed' + (Invoke-Git $fsRepo @('branch', '--show-current')) | Should Be 'release/fixture' + (Invoke-Git $fsRepo @('rev-parse', 'HEAD')) | Should Be $source + $content = [IO.File]::ReadAllText("$fsRepo\eng\Versions.props") + $content | Should Match '11' + $content | Should Match 'false' + $content.Replace("`r`n", '') | Should Not Match "`n" + ([IO.File]::ReadAllText("$fsRepo\azure-pipelines.yml")) | Should Match 'value: release/fixture' + @(Invoke-Git $fsRepo @('status', '--porcelain')).Count | Should Be 2 + } + + It 'rejects dirty worktrees before creating a branch' { + Add-Content "$fsRepo\azure-pipelines.yml" '# dirty' + { Invoke-VSBackportBranchPoint @arguments -Execute } | Should Throw 'tracked working-tree changes' + (Test-GitRef $fsRepo 'refs/heads/release/fixture') | Should Be $false + } + + It 'rejects existing and invalid branch names' { + foreach ($name in @('main', 'release/invalid name')) { + $arguments.NewBranchName = $name + { Invoke-VSBackportBranchPoint @arguments -Execute } | Should Throw 'branch' + } + (Invoke-Git $fsRepo @('branch', '--show-current')) | Should Be 'main' + } + + It 'preflights malformed pipeline mappings and version layouts' { + foreach ($file in @('azure-pipelines.yml', 'eng\Versions.props')) { + $content = [IO.File]::ReadAllText((Join-Path $fsRepo $file)) + [IO.File]::WriteAllText((Join-Path $fsRepo $file), $content.Replace('VSInsertionTargetBranchName', 'Missing').Replace('8', '$(Computed)')) + $null = Invoke-Git $fsRepo @('commit', '--quiet', '-am', 'unsupported file') + $script:source = Invoke-Git $fsRepo @('rev-parse', 'HEAD') + { Invoke-VSBackportBranchPoint @arguments -Execute } | Should Throw 'Expected one' + (Test-GitRef $fsRepo 'refs/heads/release/fixture') | Should Be $false + $null = Invoke-Git $fsRepo @('revert', '--no-edit', 'HEAD') + } + } + + It 'preflights the source SHA rather than a repaired current file' { + $null = Invoke-Git $fsRepo @('rm', '--quiet', 'eng/Versions.props') + $null = Invoke-Git $fsRepo @('commit', '--quiet', '-m', 'missing versions') + $script:source = Invoke-Git $fsRepo @('rev-parse', 'HEAD') + $null = Invoke-Git $fsRepo @('revert', '--no-edit', 'HEAD') + { Invoke-VSBackportBranchPoint @arguments -Execute } | Should Throw 'git' + { Invoke-VSBackportBranchPoint @arguments } | Should Throw 'git' + (Test-GitRef $fsRepo 'refs/heads/release/fixture') | Should Be $false + } + + It 'rejects ignored file collisions before switching branches' { + $null = Invoke-Git $fsRepo @('rm', '--quiet', 'eng/Versions.props') + $null = Invoke-Git $fsRepo @('commit', '--quiet', '-m', 'remove versions') + $null = New-Item -ItemType Directory -Path "$fsRepo\eng" -Force + [IO.File]::WriteAllText("$fsRepo\eng\Versions.props", 'user content') + [IO.File]::WriteAllText("$fsRepo\.git\info\exclude", 'eng/Versions.props') + { Invoke-VSBackportBranchPoint @arguments -Execute } | Should Throw 'collides' + (Test-GitRef $fsRepo 'refs/heads/release/fixture') | Should Be $false + ([IO.File]::ReadAllText("$fsRepo\eng\Versions.props")) | Should Be 'user content' + } + + It 'rejects a shallow false introduction and deepens to the real insertion' { + $clone = Join-Path $fixtureRoot 'shallow' + $url = ([Uri]($vsRepo + '\')).AbsoluteUri.TrimEnd('/') + $null = Invoke-Git $fixtureRoot @('clone', '--quiet', '--depth=1', '--no-checkout', $url, $clone) + $branch = Resolve-VisualStudioBranch $clone main + $snapshot = Invoke-Git $clone @('rev-parse', 'HEAD') + { Get-VisualStudioInsertion $clone $snapshot $payload $branch -MaxDeepenAttempts 0 } | Should Throw 'Insufficient' + { Get-VisualStudioInsertion $clone $snapshot $payload $branch -DeepenBy 1 -MaxDeepenAttempts 1 } | Should Throw 'Insufficient' + (Get-VisualStudioInsertion $clone $snapshot $payload $branch -DeepenBy 2 -MaxDeepenAttempts 3) | Should Be $insertion + (Resolve-VisualStudioBranch $clone 'refs/remotes/origin/main').Branch | Should Be 'main' + (Test-Path "$clone\.corext") | Should Be $false + (Invoke-Git $clone @('rev-parse', 'HEAD')) | Should Be $snapshot + } + + It 'uses and cleans an owned no-checkout clone without live services' { + Mock Get-AzureDevOpsToken { 'fixture-only-token' } + Mock Invoke-Git { + param($RepositoryPath, $Arguments, $AccessToken) + if ($Arguments[0] -eq 'clone') { + $script:ownedClone = $RepositoryPath + $Arguments = @($Arguments | ForEach-Object { + if ($_ -eq 'https://devdiv.visualstudio.com/DefaultCollection/DevDiv/_git/VS') { + ([Uri]($script:vsRepo + '\')).AbsoluteUri.TrimEnd('/') + } else { $_ } + }) + } + & $script:realGit -RepositoryPath $RepositoryPath -Arguments $Arguments -AccessToken $AccessToken + } + $arguments.Remove('VSRepoPath') + $result = Invoke-VSBackportBranchPoint @arguments + $result.VSInsertionCommit | Should Be $insertion + $result.FSharpBranchPoint | Should Be $source + (Test-Path -LiteralPath $ownedClone) | Should Be $false + (Test-Path -LiteralPath $vsRepo) | Should Be $true + } + + It 'cleans only its owned clone on authentication or clone failure' { + Mock Get-AzureDevOpsToken { 'fixture-only-token' } + Mock Invoke-Git { + param($RepositoryPath, $Arguments, $AccessToken) + if ($Arguments[0] -eq 'clone') { + $script:ownedClone = $RepositoryPath + throw 'Fixture clone denied' + } + & $script:realGit -RepositoryPath $RepositoryPath -Arguments $Arguments -AccessToken $AccessToken + } + $arguments.Remove('VSRepoPath') + { Invoke-VSBackportBranchPoint @arguments } | Should Throw 'Fixture clone denied' + (Test-Path -LiteralPath $ownedClone) | Should Be $false + (Test-Path -LiteralPath $vsRepo) | Should Be $true + } + + It 'restores process authentication settings and never persists the header' { + $old = $env:GIT_CONFIG_COUNT + try { + $env:GIT_CONFIG_COUNT = '0' + $null = Invoke-Git $vsRepo @('status', '--porcelain') -AccessToken 'fixture-only-token' + $env:GIT_CONFIG_COUNT | Should Be '0' + ([IO.File]::ReadAllText("$vsRepo\.git\config")) | Should Not Match 'fixture-only-token|extraHeader' + { Invoke-Git $vsRepo @('invalid-command') -AccessToken 'fixture-only-token' } | + Should Throw 'Authenticated Git operation failed' + $env:GIT_CONFIG_COUNT | Should Be '0' + } + finally { $env:GIT_CONFIG_COUNT = $old } + } + + It 'fails authentication before creating an owned clone' { + Mock Get-AzureDevOpsToken { throw 'Fixture authentication denied' } + $arguments.Remove('VSRepoPath') + { Invoke-VSBackportBranchPoint @arguments } | Should Throw 'Fixture authentication denied' + (Test-Path -LiteralPath $vsRepo) | Should Be $true + (Invoke-Git $fsRepo @('status', '--porcelain')) | Should BeNullOrEmpty + } + + It 'reports partial writes without rolling back the created branch' { + Mock Set-FixedVSMinorVersion { + param($Content, $Minor) + if ((Invoke-Git $script:fsRepo @('branch', '--show-current')) -eq 'release/fixture') { + throw 'Fixture write denied' + } + & $script:realSetMinor $Content $Minor + } + { Invoke-VSBackportBranchPoint @arguments -Execute } | Should Throw 'may be partial' + (Test-GitRef $fsRepo 'refs/heads/release/fixture') | Should Be $true + ([IO.File]::ReadAllText("$fsRepo\azure-pipelines.yml")) | Should Match 'value: release/fixture' + ([IO.File]::ReadAllText("$fsRepo\eng\Versions.props")) | Should Match '8' + } +} diff --git a/eng/release/scripts/Get-VSBackportBranchPoint.ps1 b/eng/release/scripts/Get-VSBackportBranchPoint.ps1 index 3f05fc28d0e..ed032bc2f10 100644 --- a/eng/release/scripts/Get-VSBackportBranchPoint.ps1 +++ b/eng/release/scripts/Get-VSBackportBranchPoint.ps1 @@ -7,15 +7,20 @@ introduced its current F# payload, and queries Azure DevOps pipeline definition 499 for the payload build's source commit. - By default, prints the command and pipeline changes needed to create a backport branch. When - -Execute is specified, creates and checks out the branch and updates azure-pipelines.yml. - Requires the Azure CLI to be installed and authenticated. + Without VSRepoPath, uses an owned shallow, no-checkout clone, deepening at most ten times + by 100 commits. The clone is removed even on failure. Requires Azure CLI authentication + with access to DevDiv/VS and dnceng/internal. + + By default, reports the command and proposed pipeline/version edits without changing F#. + -Execute creates and checks out the branch, updates azure-pipelines.yml, and fixes the + caller-supplied minor in eng\Versions.props. It does not commit, push, or insert into VS. .PARAMETER VSBranch The Visual Studio branch whose latest F# insertion should be located. .PARAMETER VSRepoPath - The path to a local clone of the Visual Studio repository. + Optional local Visual Studio clone. Its checkout is not changed. Otherwise a temporary + clone of https://devdiv.visualstudio.com/DefaultCollection/DevDiv/_git/VS is used. .PARAMETER FSharpRepoPath The path to a local clone of the dotnet/fsharp repository. @@ -23,22 +28,26 @@ .PARAMETER NewBranchName The name of the F# backport branch to create. +.PARAMETER VSMinorVersion + Required integer from 0 through 65534 for the target VS release. The caller owns this + mapping; different branches may use the same minor. Scheduled minor selection is disabled. + .PARAMETER Execute - Creates the branch and updates azure-pipelines.yml. Without this switch, no changes are made. + Creates the branch and updates both files. Without this switch, no F# changes are made. .EXAMPLE .\Get-VSBackportBranchPoint.ps1 ` -VSBranch rel/insiders ` -VSRepoPath Q:\source\VS ` -FSharpRepoPath Q:\source\fsharp ` - -NewBranchName release/dev18.10 + -NewBranchName release/dev18.11 -VSMinorVersion 11 .EXAMPLE .\Get-VSBackportBranchPoint.ps1 ` -VSBranch rel/insiders ` -VSRepoPath Q:\source\VS ` -FSharpRepoPath Q:\source\fsharp ` - -NewBranchName release/dev18.10 ` + -NewBranchName release/dev18.11 -VSMinorVersion 11 ` -Execute #> @@ -47,7 +56,6 @@ param ( [Parameter(Mandatory = $true)] [string]$VSBranch, - [Parameter(Mandatory = $true)] [string]$VSRepoPath, [Parameter(Mandatory = $true)] @@ -56,6 +64,10 @@ param ( [Parameter(Mandatory = $true)] [string]$NewBranchName, + [Parameter(Mandatory = $true)] + [ValidateScript({ $_ -cmatch '^[0-9]{1,5}$' -and [int]$_ -le 65534 })] + [string]$VSMinorVersion, + [switch]$Execute ) @@ -68,15 +80,53 @@ function Invoke-Git { [string]$RepositoryPath, [Parameter(Mandatory = $true)] - [string[]]$Arguments + [string[]]$Arguments, + + [string]$AccessToken ) - $output = & git -C $RepositoryPath @Arguments 2>&1 - if ($LASTEXITCODE -ne 0) { - throw "git -C `"$RepositoryPath`" $($Arguments -join ' ') failed:`n$($output -join "`n")" + $savedEnvironment = @{} + try { + if ($AccessToken) { + # Git's process configuration keeps the header out of argv and repository config. + $environment = @{ + GIT_CONFIG_COUNT = '1' + GIT_CONFIG_KEY_0 = 'http.extraHeader' + GIT_CONFIG_VALUE_0 = "Authorization: Bearer $AccessToken" + GIT_TERMINAL_PROMPT = '0' + GIT_TRACE = '0' + GIT_TRACE_CURL = '0' + GIT_CURL_VERBOSE = '0' + GIT_TRACE_PACKET = '0' + GIT_TRACE2 = '0' + GIT_TRACE2_EVENT = '0' + GIT_TRACE2_PERF = '0' + } + foreach ($name in $environment.Keys) { + $savedEnvironment[$name] = [Environment]::GetEnvironmentVariable($name, 'Process') + [Environment]::SetEnvironmentVariable($name, $environment[$name], 'Process') + } + } + # Windows PowerShell wraps native stderr as errors, including successful Git messages. + $ErrorActionPreference = 'Continue' + $output = & git -C $RepositoryPath @Arguments 2>&1 + $exitCode = $LASTEXITCODE + $ErrorActionPreference = 'Stop' + if ($exitCode -ne 0) { + if ($AccessToken) { throw "Authenticated Git operation failed. Check Azure CLI identity and repository access." } + throw "git -C `"$RepositoryPath`" $($Arguments -join ' ') failed:`n$($output -join "`n")" + } + return $output + } + catch { + if ($AccessToken) { throw "Authenticated Git operation failed. Check Azure CLI identity and repository access." } + throw + } + finally { + foreach ($name in $savedEnvironment.Keys) { + [Environment]::SetEnvironmentVariable($name, $savedEnvironment[$name], 'Process') + } } - - return $output } function Resolve-GitRepository { @@ -133,9 +183,12 @@ function Resolve-VisualStudioBranch { [string]$RepositoryPath, [Parameter(Mandatory = $true)] - [string]$Branch + [string]$Branch, + + [string]$AccessToken ) + $Branch = $Branch -replace '^refs/heads/', '' $localBranchRef = "refs/heads/$Branch" if (-not (Test-GitRef -RepositoryPath $RepositoryPath -Ref $localBranchRef)) { if (-not (Test-GitRef -RepositoryPath $RepositoryPath -Ref $Branch)) { @@ -143,7 +196,11 @@ function Resolve-VisualStudioBranch { } Write-Warning "Using Visual Studio ref '$Branch' without fetching because it is not a local branch with a configured upstream." - return $Branch + $fullRef = Invoke-Git -RepositoryPath $RepositoryPath -Arguments @("rev-parse", "--symbolic-full-name", $Branch) + if ($fullRef -notmatch '^refs/remotes/([^/]+)/(.+)$') { + throw "Supply a Visual Studio branch name, not an arbitrary ref or commit." + } + return [PSCustomObject]@{ Ref = $fullRef; Remote = $Matches[1]; RemoteBranch = "refs/heads/$($Matches[2])"; Branch = $Matches[2] } } $upstream = Invoke-Git -RepositoryPath $RepositoryPath -Arguments @( @@ -154,7 +211,7 @@ function Resolve-VisualStudioBranch { if (-not $upstream) { Write-Warning "Using local Visual Studio branch '$Branch' without fetching because it has no configured upstream." - return $Branch + return [PSCustomObject]@{ Ref = $localBranchRef; Branch = $Branch; Remote = $null; RemoteBranch = $null } } $remote = Invoke-Git -RepositoryPath $RepositoryPath -Arguments @( @@ -163,13 +220,49 @@ function Resolve-VisualStudioBranch { $localBranchRef ) | Select-Object -First 1 - $null = Invoke-Git -RepositoryPath $RepositoryPath -Arguments @( + $remoteBranch = Invoke-Git -RepositoryPath $RepositoryPath -Arguments @( + "for-each-ref", "--format=%(upstream:remoteref)", $localBranchRef + ) | Select-Object -First 1 + $null = Invoke-Git -RepositoryPath $RepositoryPath -AccessToken $AccessToken -Arguments @( "fetch", $remote, - "--quiet" + "--quiet", + "--no-tags", + "${remoteBranch}:refs/remotes/$upstream" ) - return $upstream + return [PSCustomObject]@{ Ref = $upstream; Branch = $remoteBranch -replace '^refs/heads/', ''; Remote = $remote; RemoteBranch = $remoteBranch } +} + +function Get-VisualStudioInsertion { + param ( + [string]$RepositoryPath, + [string]$Snapshot, + [string]$PayloadUrl, + $Branch, + [string]$AccessToken, + [int]$DeepenBy = 100, + [int]$MaxDeepenAttempts = 10 + ) + + for ($attempt = 0; $attempt -le $MaxDeepenAttempts; $attempt++) { + $candidate = Invoke-Git -RepositoryPath $RepositoryPath -Arguments @( + "log", $Snapshot, "--first-parent", "-n", "1", "--format=%H", + "-S$PayloadUrl", "--", ".corext/Configs/components.json" + ) | Select-Object -First 1 + $shallowPath = Invoke-Git -RepositoryPath $RepositoryPath -Arguments @("rev-parse", "--git-path", "shallow") + if (-not [IO.Path]::IsPathRooted($shallowPath)) { $shallowPath = Join-Path $RepositoryPath $shallowPath } + $boundaries = if (Test-Path -LiteralPath $shallowPath) { @(Get-Content -LiteralPath $shallowPath) } else { @() } + # Pickaxe treats a shallow boundary as a root, falsely attributing every payload to it. + if ($candidate -and $candidate -notin $boundaries) { return $candidate } + if (-not $boundaries) { throw "No F# insertion was found in the Visual Studio snapshot." } + if (-not $Branch.Remote -or $attempt -eq $MaxDeepenAttempts) { + throw "Insufficient Visual Studio history to prove the insertion. Deepen the local clone and retry." + } + $null = Invoke-Git -RepositoryPath $RepositoryPath -AccessToken $AccessToken -Arguments @( + "fetch", "--quiet", "--no-tags", "--deepen=$DeepenBy", $Branch.Remote, $Branch.RemoteBranch + ) + } } function Resolve-FSharpBranch { @@ -236,14 +329,59 @@ function Set-PipelineVariable { ) } +function Set-FixedVSMinorVersion { + param ( + [string]$Content, + [ValidateScript({ $_ -cmatch '^[0-9]{1,5}$' -and [int]$_ -le 65534 })] + [string]$Minor + ) + + $document = [xml]$Content + $minorNodes = $document.SelectNodes('/Project/PropertyGroup/VSMinorVersion') + $minorPattern = '\s*[0-9]+\s*' + if ($minorNodes.Count -ne 1 -or $minorNodes[0].ParentNode.HasAttribute('Condition') -or + [regex]::Matches($Content, $minorPattern).Count -ne 1) { + throw "Expected one unconditional literal VSMinorVersion in eng\Versions.props." + } + $Content = [regex]::Replace($Content, $minorPattern, "$([int]$Minor)") + $flagPattern = '[^<]*' + $flagNodes = $document.SelectNodes('/Project/PropertyGroup/UseVSScheduledMinorVersion') + $flag = 'false' + if ($flagNodes.Count -eq 0) { + $newline = if ($Content.Contains("`r`n")) { "`r`n" } else { "`n" } + $Content = [regex]::Replace($Content, "(?m)^([ `t]*)($minorPattern)", { + param($match) + $match.Value + $newline + $match.Groups[1].Value + $flag + }) + } + elseif ($flagNodes.Count -eq 1 -and -not $flagNodes[0].ParentNode.HasAttribute('Condition') -and + [regex]::Matches($Content, $flagPattern).Count -eq 1) { + $Content = [regex]::Replace($Content, $flagPattern, $flag) + } + else { throw "Unsupported scheduled minor layout in eng\Versions.props." } + + $flagIndex = $Content.IndexOf($flag, [StringComparison]::Ordinal) + $minorIndex = $Content.IndexOf('', [StringComparison]::Ordinal) + $scheduleImport = [regex]::Match($Content, ']*\bProject="[^"]*VSMinorVersion\.props"[^>]*/>') + if ($flagIndex -lt 0 -or ($scheduleImport.Success -and + ($scheduleImport.Index -lt $flagIndex -or $scheduleImport.Index -lt $minorIndex))) { + throw "The fixed minor and schedule opt-out must precede the schedule import in eng\Versions.props." + } + return $Content +} + function Get-AzureDevOpsToken { + $ErrorActionPreference = 'Continue' $token = & az account get-access-token ` --resource "499b84ac-1321-427f-aa17-267ca6975798" ` --query accessToken ` + --only-show-errors ` -o tsv 2>&1 + $exitCode = $LASTEXITCODE + $ErrorActionPreference = 'Stop' - if ($LASTEXITCODE -ne 0 -or -not $token) { - throw "Unable to acquire an Azure DevOps access token. Run 'az login' and try again.`n$($token -join "`n")" + if ($exitCode -ne 0 -or -not $token) { + throw "Unable to acquire an Azure DevOps access token. Run 'az login' and try again." } return $token | Select-Object -First 1 @@ -287,175 +425,227 @@ function Get-FSharpBuild { return $builds[0] } -$vsRepository = Resolve-GitRepository -Path $VSRepoPath -Name "Visual Studio" -$fsharpRepository = Resolve-GitRepository -Path $FSharpRepoPath -Name "F#" - -$null = Invoke-Git -RepositoryPath $fsharpRepository -Arguments @( - "check-ref-format", - "--branch", - $NewBranchName -) - -if (Test-GitRef -RepositoryPath $fsharpRepository -Ref "refs/heads/$NewBranchName") { - throw "Local F# branch '$NewBranchName' already exists." -} +function Invoke-VSBackportBranchPoint { + param ( + [Parameter(Mandatory = $true)][string]$VSBranch, + [string]$VSRepoPath, + [Parameter(Mandatory = $true)][string]$FSharpRepoPath, + [Parameter(Mandatory = $true)][string]$NewBranchName, + [Parameter(Mandatory = $true)] + [ValidateScript({ $_ -cmatch '^[0-9]{1,5}$' -and [int]$_ -le 65534 })] + [string]$VSMinorVersion, + [switch]$Execute + ) -if ($NewBranchName -notmatch "^(feature|release)/") { - Write-Warning "Branch '$NewBranchName' is not covered by the pipeline's feature/* or release/* CI triggers." -} + $fsharpRepository = Resolve-GitRepository -Path $FSharpRepoPath -Name "F#" -if ($Execute) { - $trackedChanges = Invoke-Git -RepositoryPath $fsharpRepository -Arguments @( - "status", - "--porcelain", - "--untracked-files=no" + $null = Invoke-Git -RepositoryPath $fsharpRepository -Arguments @( + "check-ref-format", + "--branch", + $NewBranchName ) - if ($trackedChanges) { - throw "The F# repository has tracked working-tree changes. Commit or stash them before using -Execute." + if (Test-GitRef -RepositoryPath $fsharpRepository -Ref "refs/heads/$NewBranchName") { + throw "Local F# branch '$NewBranchName' already exists." } -} -$vsBranchRef = Resolve-VisualStudioBranch -RepositoryPath $vsRepository -Branch $VSBranch + if ($NewBranchName -notmatch "^(feature|release)/") { + Write-Warning "Branch '$NewBranchName' is not covered by the pipeline's feature/* or release/* CI triggers." + } -$componentsPath = ".corext/Configs/components.json" -$branchComponentsJson = (Invoke-Git -RepositoryPath $vsRepository -Arguments @( - "show", - "${vsBranchRef}:$componentsPath" -)) -join "`n" -$branchComponents = $branchComponentsJson | ConvertFrom-Json -$fsharpComponent = $branchComponents.Components."Microsoft.FSharp" + if ($Execute) { + $trackedChanges = Invoke-Git -RepositoryPath $fsharpRepository -Arguments @( + "status", + "--porcelain", + "--untracked-files=no" + ) -if (-not $fsharpComponent) { - throw "The F# component was not found in $componentsPath on Visual Studio branch '$VSBranch'." -} - -$insertionCommit = Invoke-Git -RepositoryPath $vsRepository -Arguments @( - "log", - $vsBranchRef, - "--first-parent", - "-n", - "1", - "--format=%H", - "-S$($fsharpComponent.url)", - "--", - $componentsPath -) + if ($trackedChanges) { + throw "The F# repository has tracked working-tree changes. Commit or stash them before using -Execute." + } + } -if (-not $insertionCommit) { - throw "No F# insertion was found on Visual Studio branch '$VSBranch'." -} + $ownedRepository = $null + try { + $accessToken = Get-AzureDevOpsToken + if ($VSRepoPath) { + $vsRepository = Resolve-GitRepository -Path $VSRepoPath -Name "Visual Studio" + } + else { + $VSBranch = $VSBranch -replace '^refs/heads/', '' + $null = Invoke-Git -RepositoryPath $fsharpRepository -Arguments @("check-ref-format", "refs/heads/$VSBranch") + # Keep scratch storage invocation-owned and under the caller's working directory. + $clonePath = Join-Path (Get-Location).Path (".vs-backport-" + [Guid]::NewGuid().ToString('N')) + $null = New-Item -ItemType Directory -Path $clonePath + $ownedRepository = $clonePath + $null = Invoke-Git -RepositoryPath $ownedRepository -AccessToken $accessToken -Arguments @( + "clone", "--quiet", "--depth=100", "--single-branch", "--no-tags", "--no-checkout", + "--branch", $VSBranch, "https://devdiv.visualstudio.com/DefaultCollection/DevDiv/_git/VS", "." + ) + $vsRepository = $ownedRepository + } -$insertionCommit = $insertionCommit | Select-Object -First 1 -$insertionDate = (Invoke-Git -RepositoryPath $vsRepository -Arguments @( - "show", - "-s", - "--format=%cI", - $insertionCommit -)) | Select-Object -First 1 -$insertionSubject = (Invoke-Git -RepositoryPath $vsRepository -Arguments @( - "show", - "-s", - "--format=%s", - $insertionCommit -)) | Select-Object -First 1 - -$buildNumberMatch = [regex]::Match( - $fsharpComponent.url, - "/dotnet-fsharp/(?.+)/(?[^/;]+);", - [System.Text.RegularExpressions.RegexOptions]::IgnoreCase -) -if (-not $buildNumberMatch.Success) { - throw "Unable to extract the F# source branch and build number from component URL '$($fsharpComponent.url)'." -} + $vsBranchInfo = Resolve-VisualStudioBranch -RepositoryPath $vsRepository -Branch $VSBranch -AccessToken $accessToken + $vsBranchRef = $vsBranchInfo.Ref + $VSBranch = $vsBranchInfo.Branch + $vsSnapshot = Invoke-Git -RepositoryPath $vsRepository -Arguments @("rev-parse", "$vsBranchRef^{commit}") | + Select-Object -First 1 + + $componentsPath = ".corext/Configs/components.json" + $branchComponentsJson = (Invoke-Git -RepositoryPath $vsRepository -Arguments @( + "show", + "${vsSnapshot}:$componentsPath" + )) -join "`n" + $branchComponents = $branchComponentsJson | ConvertFrom-Json + $fsharpComponent = $branchComponents.Components."Microsoft.FSharp" + + if (-not $fsharpComponent) { + throw "The F# component was not found in $componentsPath on Visual Studio branch '$VSBranch'." + } -$sourceBranch = [Uri]::UnescapeDataString($buildNumberMatch.Groups["sourceBranch"].Value) -$buildNumber = $buildNumberMatch.Groups["buildNumber"].Value -$build = Get-FSharpBuild -BuildNumber $buildNumber -SourceBranch $sourceBranch -$branchPoint = $build.sourceVersion + $insertionCommit = Get-VisualStudioInsertion -RepositoryPath $vsRepository -Snapshot $vsSnapshot ` + -PayloadUrl $fsharpComponent.url -Branch $vsBranchInfo -AccessToken $accessToken + $insertionDate = (Invoke-Git -RepositoryPath $vsRepository -Arguments @( + "show", + "-s", + "--format=%cI", + $insertionCommit + )) | Select-Object -First 1 + $insertionSubject = (Invoke-Git -RepositoryPath $vsRepository -Arguments @( + "show", + "-s", + "--format=%s", + $insertionCommit + )) | Select-Object -First 1 + + $buildNumberMatch = [regex]::Match( + $fsharpComponent.url, + "/dotnet-fsharp/(?.+)/(?[^/;]+);", + [System.Text.RegularExpressions.RegexOptions]::IgnoreCase + ) + if (-not $buildNumberMatch.Success) { + throw "Unable to extract the F# source branch and build number from component URL '$($fsharpComponent.url)'." + } -if ($branchPoint -notmatch "^[0-9a-f]{40}$") { - throw "Build '$buildNumber' returned invalid source version '$branchPoint'." -} + $sourceBranch = [Uri]::UnescapeDataString($buildNumberMatch.Groups["sourceBranch"].Value) + $buildNumber = $buildNumberMatch.Groups["buildNumber"].Value + $build = Get-FSharpBuild -BuildNumber $buildNumber -SourceBranch $sourceBranch + $branchPoint = $build.sourceVersion -$fsharpBranchRef = Resolve-FSharpBranch ` - -RepositoryPath $fsharpRepository ` - -Branch $sourceBranch ` - -Commit $branchPoint - -$branchPointDate = (Invoke-Git -RepositoryPath $fsharpRepository -Arguments @( - "show", - "-s", - "--format=%cI", - $branchPoint -)) | Select-Object -First 1 -$branchPointSubject = (Invoke-Git -RepositoryPath $fsharpRepository -Arguments @( - "show", - "-s", - "--format=%s", - $branchPoint -)) | Select-Object -First 1 -$componentVersionProperty = $fsharpComponent.PSObject.Properties["version"] -$fsharpRepoLiteral = ConvertTo-PowerShellLiteral -Value $fsharpRepository -$newBranchLiteral = ConvertTo-PowerShellLiteral -Value $NewBranchName -$branchPointLiteral = ConvertTo-PowerShellLiteral -Value $branchPoint -$gitCommand = "git -C $fsharpRepoLiteral switch -c $newBranchLiteral $branchPointLiteral" -$action = "Preview" - -if ($Execute) { - $pipelinePath = Join-Path $fsharpRepository "azure-pipelines.yml" - # Validate both mappings before creating the branch; edit the checked-out file afterward to preserve its formatting. - $branchPointPipelineContent = (Invoke-Git -RepositoryPath $fsharpRepository -Arguments @( - "show", - "${branchPoint}:azure-pipelines.yml" - )) -join "`n" - $branchPointPipelineContent = Set-PipelineVariable ` - -Content $branchPointPipelineContent ` - -Name "FSharpReleaseBranchName" ` - -Value $NewBranchName - $null = Set-PipelineVariable ` - -Content $branchPointPipelineContent ` - -Name "VSInsertionTargetBranchName" ` - -Value $VSBranch + if ($branchPoint -notmatch "^[0-9a-f]{40}$") { + throw "Build '$buildNumber' returned invalid source version '$branchPoint'." + } - $null = Invoke-Git -RepositoryPath $fsharpRepository -Arguments @( - "switch", - "-c", - $NewBranchName, - $branchPoint - ) + $fsharpBranchRef = Resolve-FSharpBranch ` + -RepositoryPath $fsharpRepository ` + -Branch $sourceBranch ` + -Commit $branchPoint + + $branchPointDate = (Invoke-Git -RepositoryPath $fsharpRepository -Arguments @( + "show", + "-s", + "--format=%cI", + $branchPoint + )) | Select-Object -First 1 + $branchPointSubject = (Invoke-Git -RepositoryPath $fsharpRepository -Arguments @( + "show", + "-s", + "--format=%s", + $branchPoint + )) | Select-Object -First 1 + $componentVersionProperty = $fsharpComponent.PSObject.Properties["version"] + $fsharpRepoLiteral = ConvertTo-PowerShellLiteral -Value $fsharpRepository + $newBranchLiteral = ConvertTo-PowerShellLiteral -Value $NewBranchName + $branchPointLiteral = ConvertTo-PowerShellLiteral -Value $branchPoint + $gitCommand = "git -C $fsharpRepoLiteral switch -c $newBranchLiteral $branchPointLiteral" + $action = "Preview" + + # Preflight both files at the exact source SHA, even in preview. + $branchPointPipelineContent = (Invoke-Git -RepositoryPath $fsharpRepository -Arguments @( + "show", "${branchPoint}:azure-pipelines.yml" + )) -join "`n" + $branchPointPipelineContent = Set-PipelineVariable ` + -Content $branchPointPipelineContent -Name "FSharpReleaseBranchName" -Value $NewBranchName + $null = Set-PipelineVariable ` + -Content $branchPointPipelineContent -Name "VSInsertionTargetBranchName" -Value $VSBranch + $branchPointVersionsContent = (Invoke-Git -RepositoryPath $fsharpRepository -Arguments @( + "show", "${branchPoint}:eng/Versions.props" + )) -join "`n" + $null = Set-FixedVSMinorVersion -Content $branchPointVersionsContent -Minor $VSMinorVersion + + if ($Execute) { + $targetFiles = @(Invoke-Git -RepositoryPath $fsharpRepository -Arguments @("ls-tree", "-r", "--name-only", $branchPoint)) + $untrackedPaths = @(Invoke-Git -RepositoryPath $fsharpRepository -Arguments @("ls-files", "--others", "--directory", "--no-empty-directory")) + foreach ($path in $untrackedPaths) { + foreach ($target in $targetFiles) { + if ($target -eq $path -or $target.StartsWith($path.TrimEnd('/') + '/', [StringComparison]::OrdinalIgnoreCase) -or + $path.StartsWith($target + '/', [StringComparison]::OrdinalIgnoreCase)) { + throw "Untracked or ignored path '$path' collides with the selected F# source commit." + } + } + } + $null = Invoke-Git -RepositoryPath $fsharpRepository -Arguments @( + "switch", + "--no-overwrite-ignore", + "-c", + $NewBranchName, + $branchPoint + ) + + try { + foreach ($file in @("azure-pipelines.yml", "eng\Versions.props")) { + $path = Join-Path $fsharpRepository $file + $content = [IO.File]::ReadAllText($path) + if ($file -eq "azure-pipelines.yml") { + $content = Set-PipelineVariable -Content $content -Name "FSharpReleaseBranchName" -Value $NewBranchName + $content = Set-PipelineVariable -Content $content -Name "VSInsertionTargetBranchName" -Value $VSBranch + } + else { + $content = Set-FixedVSMinorVersion -Content $content -Minor $VSMinorVersion + } + $bytes = [IO.File]::ReadAllBytes($path) + $hasBom = $bytes.Length -ge 3 -and $bytes[0] -eq 239 -and $bytes[1] -eq 187 -and $bytes[2] -eq 191 + [IO.File]::WriteAllText($path, $content, [Text.UTF8Encoding]::new($hasBom)) + } + } + catch { + throw "Branch '$NewBranchName' was created, but edits failed and may be partial. Inspect the worktree; no rollback was attempted. $($_.Exception.Message)" + } + $action = "Executed" + } - $pipelineContent = [IO.File]::ReadAllText($pipelinePath) - $pipelineContent = Set-PipelineVariable ` - -Content $pipelineContent ` - -Name "FSharpReleaseBranchName" ` - -Value $NewBranchName - $pipelineContent = Set-PipelineVariable ` - -Content $pipelineContent ` - -Name "VSInsertionTargetBranchName" ` - -Value $VSBranch - [IO.File]::WriteAllText($pipelinePath, $pipelineContent) - $action = "Executed" + [PSCustomObject]@{ + Action = $action + VSBranch = $VSBranch + VSResolvedBranchRef = $vsBranchRef + VSSnapshot = $vsSnapshot + VSInsertionCommit = $insertionCommit + VSInsertionDate = $insertionDate + VSInsertionSubject = $insertionSubject + FSharpComponentVersion = if ($componentVersionProperty) { $componentVersionProperty.Value } else { $null } + FSharpComponentUrl = $fsharpComponent.url + FSharpSourceBranch = $sourceBranch + FSharpResolvedBranchRef = $fsharpBranchRef + FSharpBuildNumber = $buildNumber + FSharpBuildId = $build.id + FSharpBranchPoint = $branchPoint + FSharpBranchPointDate = $branchPointDate + FSharpBranchPointSubject = $branchPointSubject + NewBranchName = $NewBranchName + GitCommand = $gitCommand + PipelineFSharpBranch = $NewBranchName + PipelineVSBranch = $VSBranch + VSMinorVersion = [int]$VSMinorVersion + UseVSScheduledMinorVersion = $false + PlannedEdits = @("azure-pipelines.yml: FSharpReleaseBranchName=$NewBranchName; VSInsertionTargetBranchName=$VSBranch", + "eng\Versions.props: VSMinorVersion=$([int]$VSMinorVersion); UseVSScheduledMinorVersion=false") + } + } + finally { + if ($ownedRepository) { Remove-Item -LiteralPath $ownedRepository -Recurse -Force } + } } -[PSCustomObject]@{ - Action = $action - VSBranch = $VSBranch - VSResolvedBranchRef = $vsBranchRef - VSInsertionCommit = $insertionCommit - VSInsertionDate = $insertionDate - VSInsertionSubject = $insertionSubject - FSharpComponentVersion = if ($componentVersionProperty) { $componentVersionProperty.Value } else { $null } - FSharpComponentUrl = $fsharpComponent.url - FSharpSourceBranch = $sourceBranch - FSharpResolvedBranchRef = $fsharpBranchRef - FSharpBuildNumber = $buildNumber - FSharpBuildId = $build.id - FSharpBranchPoint = $branchPoint - FSharpBranchPointDate = $branchPointDate - FSharpBranchPointSubject = $branchPointSubject - NewBranchName = $NewBranchName - GitCommand = $gitCommand - PipelineFSharpBranch = $NewBranchName - PipelineVSBranch = $VSBranch +if ($MyInvocation.InvocationName -ne '.') { + Invoke-VSBackportBranchPoint @PSBoundParameters } diff --git a/eng/release/scripts/VSMinorVersion.Tests.ps1 b/eng/release/scripts/VSMinorVersion.Tests.ps1 new file mode 100644 index 00000000000..e1d20b5889e --- /dev/null +++ b/eng/release/scripts/VSMinorVersion.Tests.ps1 @@ -0,0 +1,178 @@ +$versionProject = Join-Path $PSScriptRoot '..\VSVersion.proj' + +function Invoke-VersionEvaluation { + param ([hashtable]$Properties = @{}, [string]$Project = $versionProject, [switch]$EvaluationOnly) + + $arguments = @('msbuild', $Project, '-nologo', '-warnaserror', '-p:VSBuildTimestampUtc=') + $arguments += if ($EvaluationOnly) { '-getProperty:VSMinorVersion,VSMinorVersionIsHeuristic' } else { '-t:PrintVSVersion' } + foreach ($name in $Properties.Keys) { + $arguments += "-p:$name=$($Properties[$name])" + } + $output = & dotnet @arguments 2>&1 + [PSCustomObject]@{ ExitCode = $LASTEXITCODE; Output = $output -join "`n" } +} + +Describe 'Heuristic VS minor versions' { + $cases = @() + $transitions = @( + @{ At = '2026-05-02T19:00:00Z'; Minor = 8 }, + @{ At = '2026-05-30T19:00:00Z'; Minor = 9 }, + @{ At = '2026-07-04T19:00:00Z'; Minor = 10 }, + @{ At = '2026-08-01T19:00:00Z'; Minor = 11 }, + @{ At = '2026-08-29T19:00:00Z'; Minor = 12 }, + @{ At = '2026-10-03T19:00:00Z'; Minor = 13 }, + @{ At = '2026-10-31T19:00:00Z'; Minor = 14 }, + @{ At = '2026-11-28T20:00:00Z'; Minor = 15 }, + @{ At = '2027-01-02T20:00:00Z'; Minor = 16 }, + @{ At = '2027-02-27T20:00:00Z'; Minor = 18 }, + @{ At = '2027-04-03T19:00:00Z'; Minor = 19 }, + @{ At = '2028-03-04T20:00:00Z'; Minor = 30 } + ) + foreach ($transition in $transitions) { + $at = [DateTimeOffset]::Parse($transition.At) + foreach ($ticks in @(-1, 0, 1)) { + $cases += @{ + Timestamp = $at.AddTicks($ticks).ToString('o') + Minor = $transition.Minor - [int]($ticks -lt 0) + } + } + } + + It 'selects minor at ' -TestCases $cases { + param ($Timestamp, $Minor) + $result = Invoke-VersionEvaluation @{ VSBuildTimestampUtc = $Timestamp } + $result.ExitCode | Should Be 0 + $result.Output | Should Match "VSMinorVersion=$Minor; VSAssemblyVersion=18\.$Minor\.0\.0" + $result.Output | Should Match 'warning FSVS1001' + } + + It 'uses the literal local minor without a timestamp' { + $result = Invoke-VersionEvaluation + $result.ExitCode | Should Be 0 + $result.Output | Should Match 'VSMinorVersion=8;' + $result.Output | Should Not Match 'FSVS1001' + } + + It 'retains the minor during the 24-hour delay at ' -TestCases @( + @{ Timestamp = '2026-08-28T19:00:00Z'; Minor = 11 }, + @{ Timestamp = '2026-07-03T19:00:00Z'; Minor = 9 }, + @{ Timestamp = '2026-10-30T19:00:00Z'; Minor = 13 }, + @{ Timestamp = '2026-11-27T20:00:00Z'; Minor = 14 } + ) { + param ($Timestamp, $Minor) + $result = Invoke-VersionEvaluation @{ VSBuildTimestampUtc = $Timestamp } + $result.ExitCode | Should Be 0 + $result.Output | Should Match "VSMinorVersion=$Minor;" + } + + It 'normalizes equivalent offsets including winter offsets' -TestCases @( + @{ Timestamp = '2026-08-29T12:00:00-07:00'; Minor = 12 }, + @{ Timestamp = '2026-08-29T21:00:00+02:00'; Minor = 12 }, + @{ Timestamp = '2026-11-28T12:00:00-08:00'; Minor = 15 }, + @{ Timestamp = '2026-10-31T12:00:00-07:00'; Minor = 14 } + ) { + param ($Timestamp, $Minor) + $result = Invoke-VersionEvaluation @{ VSBuildTimestampUtc = $Timestamp } + $result.ExitCode | Should Be 0 + $result.Output | Should Match "VSMinorVersion=$Minor;" + } + + It 'keeps a fixed backport minor with a later timestamp' { + $result = Invoke-VersionEvaluation @{ + UseVSScheduledMinorVersion = 'false' + VSMinorVersion = '11' + VSBuildTimestampUtc = '2026-12-02T19:00:00Z' + } + $result.ExitCode | Should Be 0 + $result.Output | Should Match 'VSMinorVersion=11;' + $result.Output | Should Not Match 'FSVS1001' + } + + It 'rejects invalid input ' -TestCases @( + @{ Timestamp = 'garbage' }, + @{ Timestamp = '2026-09-11T12:00:00' }, + @{ Timestamp = '2024-01-01T00:00:00Z' }, + @{ Timestamp = '9999-12-31T19:00:00Z' } + ) { + param ($Timestamp) + (Invoke-VersionEvaluation @{ VSBuildTimestampUtc = $Timestamp }).ExitCode | Should Not Be 0 + } + + It 'requires a deliberate calibration update for another VS major' { + $result = Invoke-VersionEvaluation @{ VSMajorVersion = 19; VSBuildTimestampUtc = '2026-09-11T12:00:00Z' } + $result.ExitCode | Should Not Be 0 + $result.Output | Should Match 'covers major 18 only' + } + + It 'rejects computed minors outside the assembly component range' { + $result = Invoke-VersionEvaluation @{ VSBuildTimestampUtc = '8000-01-01T00:00:00Z' } + $result.ExitCode | Should Not Be 0 + $result.Output | Should Match 'exceeds 65534' + } + + It 'rejects invalid input during evaluation alone with Arcade disabled=' -TestCases @( + @{ Disabled = 'true' }, + @{ Disabled = 'false' } + ) { + param ($Disabled) + $project = Join-Path $PSScriptRoot '..\..\..\vsintegration\src\FSharp.ProjectSystem.FSharp\FSharp.ProjectSystem.FSharp.fsproj' + foreach ($invalid in @( + @{ VSBuildTimestampUtc = 'garbage' }, + @{ VSBuildTimestampUtc = '2026-08-29T20:30:00' }, + @{ VSBuildTimestampUtc = '2024-01-01T00:00:00Z' }, + @{ VSBuildTimestampUtc = '8000-01-01T00:00:00Z' }, + @{ VSBuildTimestampUtc = '2026-09-11T12:00:00Z'; VSMajorVersion = '19' } + )) { + $invalid.DISABLE_ARCADE = $Disabled + $result = Invoke-VersionEvaluation $invalid -Project $project -EvaluationOnly + $result.ExitCode | Should Not Be 0 + } + } + + It 'exposes heuristic status and normalizes offsets during evaluation alone' { + foreach ($timestamp in @('2026-08-29T19:00:00Z', '2026-08-29T21:00:00+02:00', '2026-08-29T12:00:00-07:00')) { + $result = Invoke-VersionEvaluation @{ VSBuildTimestampUtc = $timestamp } -EvaluationOnly + $result.ExitCode | Should Be 0 + $properties = ($result.Output | ConvertFrom-Json).Properties + $properties.VSMinorVersion | Should Be '12' + $properties.VSMinorVersionIsHeuristic | Should Be 'true' + } + foreach ($fixed in @( + @{}, + @{ UseVSScheduledMinorVersion = 'false'; VSMinorVersion = '11'; VSBuildTimestampUtc = 'not-a-timestamp' } + )) { + $result = Invoke-VersionEvaluation $fixed -EvaluationOnly + $result.ExitCode | Should Be 0 + ($result.Output | ConvertFrom-Json).Properties.VSMinorVersionIsHeuristic | Should Be 'false' + } + } + + It 'propagates the selected minor to VS redirects with Arcade disabled=' -TestCases @( + @{ Disabled = 'true' }, + @{ Disabled = 'false' } + ) { + param ($Disabled) + $project = Join-Path $PSScriptRoot '..\..\..\vsintegration\src\FSharp.ProjectSystem.FSharp\FSharp.ProjectSystem.FSharp.fsproj' + $output = & dotnet msbuild $project -nologo "-p:DISABLE_ARCADE=$Disabled" ` + -p:VSBuildTimestampUtc=2026-09-11T12:00:00Z ` + -getProperty:VSAssemblyVersion,FSCoreVersion,FSharpCompilerServiceVersion -getItem:AssemblyAttribute + $LASTEXITCODE | Should Be 0 + $data = ($output -join "`n") | ConvertFrom-Json + $data.Properties.VSAssemblyVersion | Should Be '18.12.0.0' + $redirects = @($data.Items.AssemblyAttribute | Where-Object { + $_.Identity -eq 'Microsoft.VisualStudio.Shell.ProvideBindingRedirectionAttribute' -and + $_.AssemblyName -ne 'FSharp.Core' + }) + $redirects.Count | Should Be 2 + foreach ($redirect in $redirects) { + $redirect.OldVersionUpperBound | Should Be '18.12.0.0' + $redirect.NewVersion | Should Be '18.12.0.0' + } + $baseline = & dotnet msbuild $project -nologo "-p:DISABLE_ARCADE=$Disabled" ` + -p:VSBuildTimestampUtc= -getProperty:FSCoreVersion,FSharpCompilerServiceVersion + $LASTEXITCODE | Should Be 0 + $unchanged = ($baseline -join "`n") | ConvertFrom-Json + $data.Properties.FSCoreVersion | Should Be $unchanged.Properties.FSCoreVersion + $data.Properties.FSharpCompilerServiceVersion | Should Be $unchanged.Properties.FSharpCompilerServiceVersion + } +} From 4b8daaaf7d884d1b3a889f84bcb228dca1563322 Mon Sep 17 00:00:00 2001 From: Adam Boniecki <20281641+abonie@users.noreply.github.com> Date: Mon, 5 Oct 2026 19:23:17 +0200 Subject: [PATCH 3/6] Log backport progress and fetch missing FSharp source commits Keep command-line progress separate from structured output. Refresh source-branch refs when the inserted build commit is absent from the local clone, with regression coverage and usage documentation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 14fe971f-b624-4dbb-b9af-7683df5c1542 --- eng/release/README.md | 16 ++++++- .../Get-VSBackportBranchPoint.Tests.ps1 | 38 ++++++++++++++++ .../scripts/Get-VSBackportBranchPoint.ps1 | 44 ++++++++++++++++++- 3 files changed, 95 insertions(+), 3 deletions(-) diff --git a/eng/release/README.md b/eng/release/README.md index d4069051de9..f69cbf7bbf3 100644 --- a/eng/release/README.md +++ b/eng/release/README.md @@ -9,7 +9,8 @@ branch, configure its insertion target, and pin its VS minor version. - Git, PowerShell, and an authenticated Azure CLI (`az login`). - Permission to read DevDiv's VS repository and dnceng/internal F# build metadata. Signing in alone does not grant either permission. -- A local F# clone containing the source branch and commit being serviced. +- A local F# clone with an `upstream` or `origin` remote containing the source + branch, or with the source commit already available on its local refs. ## Preview and create a branch @@ -37,6 +38,19 @@ failure. Initial depth is 100 commits; it deepens by 100 commits up to ten times If the history limit is reached, supply a clone with sufficient history. Preview can fetch refs and use temporary disk space but does not edit the F# worktree. +If the inserted build's source commit is absent from cached F# refs, the script +fetches that source branch from configured `upstream`, then `origin` if needed. +This updates remote-tracking refs without changing your checkout or local edits. +Fetch failures are reported; check the remote URL and repository permissions. +If a shallow F# clone still lacks the source commit, deepen it and retry. + +Progress messages appear by default on PowerShell's information stream (6), +covering authentication, clone/fetch/deepening, build lookup, validation, edits, +and cleanup. The final structured result is the only success-stream output, so +assigning it to a variable or piping it to `ConvertTo-Json` still works. +Use `6>$null` to hide progress, or `6>progress.log` to capture it separately. +Credentials and authorization headers are not logged. + The caller is responsible for choosing the correct release minor. Two VS release branches can legitimately target the same minor. A backport has `UseVSScheduledMinorVersion=false`, so later builds retain the supplied minor even diff --git a/eng/release/scripts/Get-VSBackportBranchPoint.Tests.ps1 b/eng/release/scripts/Get-VSBackportBranchPoint.Tests.ps1 index 08b99292ca6..7a24a37d0b2 100644 --- a/eng/release/scripts/Get-VSBackportBranchPoint.Tests.ps1 +++ b/eng/release/scripts/Get-VSBackportBranchPoint.Tests.ps1 @@ -136,6 +136,20 @@ Describe 'Backport Git fixtures' { (Invoke-Git $vsRepo @('rev-parse', 'HEAD')) | Should Be $vsHead } + It 'reports visible progress separately from the structured result without credentials' { + $output = @(Invoke-VSBackportBranchPoint @arguments 6>&1) + $progress = @($output | Where-Object { $_ -is [System.Management.Automation.InformationRecord] }) + $results = @($output | Where-Object { $_ -isnot [System.Management.Automation.InformationRecord] }) + $results.Count | Should Be 1 + $results[0].Action | Should Be 'Preview' + $messages = ($progress | ForEach-Object { $_.MessageData }) -join "`n" + $messages | Should Match 'Acquiring Azure DevOps credentials' + $messages | Should Match 'Looking up fsharp-ci build' + $messages | Should Match 'Validating pipeline mappings' + $messages | Should Match 'Preview complete' + $messages | Should Not Match 'fixture-only-token|Authorization' + } + It 'executes only in the fixture, preserving newlines and leaving uncommitted edits' { $result = Invoke-VSBackportBranchPoint @arguments -Execute $result.Action | Should Be 'Executed' @@ -155,6 +169,30 @@ Describe 'Backport Git fixtures' { (Test-GitRef $fsRepo 'refs/heads/release/fixture') | Should Be $false } + It 'fetches a missing F# source commit without changing checkout or edits' { + $publisher = Join-Path $fixtureRoot 'publisher' + $null = Invoke-Git $fixtureRoot @('clone', '--quiet', $fsRepo, $publisher) + $null = Invoke-Git $publisher @('config', 'user.name', 'Fixture') + $null = Invoke-Git $publisher @('config', 'user.email', 'fixture@example.invalid') + $null = Invoke-Git $publisher @('config', 'commit.gpgsign', 'false') + $null = Invoke-Git $publisher @('commit', '--quiet', '--allow-empty', '-m', 'new inserted source') + $newSource = Invoke-Git $publisher @('rev-parse', 'HEAD') + $null = Invoke-Git $fsRepo @('remote', 'add', 'upstream', $publisher) + [IO.File]::AppendAllText("$fsRepo\azure-pipelines.yml", '# local edit') + $head = Invoke-Git $fsRepo @('rev-parse', 'HEAD') + $content = [IO.File]::ReadAllText("$fsRepo\azure-pipelines.yml") + (Test-GitRef $fsRepo $newSource) | Should Be $false + (Resolve-FSharpBranch $fsRepo main $newSource) | Should Be 'refs/remotes/upstream/main' + (Test-GitRef $fsRepo $newSource) | Should Be $true + (Invoke-Git $fsRepo @('rev-parse', 'HEAD')) | Should Be $head + ([IO.File]::ReadAllText("$fsRepo\azure-pipelines.yml")) | Should Be $content + } + + It 'does not fetch when a cached F# ref contains the source commit' { + $null = Invoke-Git $fsRepo @('remote', 'add', 'upstream', (Join-Path $fixtureRoot 'nonexistent-remote')) + (Resolve-FSharpBranch $fsRepo main $source) | Should Be 'main' + } + It 'rejects existing and invalid branch names' { foreach ($name in @('main', 'release/invalid name')) { $arguments.NewBranchName = $name diff --git a/eng/release/scripts/Get-VSBackportBranchPoint.ps1 b/eng/release/scripts/Get-VSBackportBranchPoint.ps1 index ed032bc2f10..d2145f61643 100644 --- a/eng/release/scripts/Get-VSBackportBranchPoint.ps1 +++ b/eng/release/scripts/Get-VSBackportBranchPoint.ps1 @@ -14,6 +14,7 @@ By default, reports the command and proposed pipeline/version edits without changing F#. -Execute creates and checks out the branch, updates azure-pipelines.yml, and fixes the caller-supplied minor in eng\Versions.props. It does not commit, push, or insert into VS. + Progress is displayed on the information stream (6); the final result remains on stdout. .PARAMETER VSBranch The Visual Studio branch whose latest F# insertion should be located. @@ -223,6 +224,7 @@ function Resolve-VisualStudioBranch { $remoteBranch = Invoke-Git -RepositoryPath $RepositoryPath -Arguments @( "for-each-ref", "--format=%(upstream:remoteref)", $localBranchRef ) | Select-Object -First 1 + Write-Information "[VS backport] Fetching VS upstream '$upstream'." -InformationAction Continue $null = Invoke-Git -RepositoryPath $RepositoryPath -AccessToken $AccessToken -Arguments @( "fetch", $remote, @@ -259,6 +261,7 @@ function Get-VisualStudioInsertion { if (-not $Branch.Remote -or $attempt -eq $MaxDeepenAttempts) { throw "Insufficient Visual Studio history to prove the insertion. Deepen the local clone and retry." } + Write-Information "[VS backport] Deepening VS history by $DeepenBy commits (attempt $($attempt + 1)/$MaxDeepenAttempts)." -InformationAction Continue $null = Invoke-Git -RepositoryPath $RepositoryPath -AccessToken $AccessToken -Arguments @( "fetch", "--quiet", "--no-tags", "--deepen=$DeepenBy", $Branch.Remote, $Branch.RemoteBranch ) @@ -290,7 +293,22 @@ function Resolve-FSharpBranch { } } - throw "F# source commit '$Commit' was not found on a local, upstream, or origin ref for branch '$Branch'. Fetch the F# repository and try again." + $null = Invoke-Git -RepositoryPath $RepositoryPath -Arguments @("check-ref-format", "refs/heads/$Branch") + $remotes = @(Invoke-Git -RepositoryPath $RepositoryPath -Arguments @("remote")) + foreach ($remote in @("upstream", "origin")) { + if ($remote -notin $remotes) { continue } + $candidate = "refs/remotes/$remote/$Branch" + Write-Information "[VS backport] Source commit is not on a cached F# ref; fetching '$remote/$Branch'." -InformationAction Continue + $null = Invoke-Git -RepositoryPath $RepositoryPath -Arguments @( + "fetch", "--quiet", "--no-tags", $remote, "+refs/heads/${Branch}:$candidate" + ) + if ((Test-GitRef -RepositoryPath $RepositoryPath -Ref $candidate) -and + (Test-GitAncestor -RepositoryPath $RepositoryPath -Ancestor $Commit -Descendant $candidate)) { + return $candidate + } + } + + throw "F# source commit '$Commit' was not found on branch '$Branch' after checking local refs and fetching configured upstream/origin remotes. Check the remote URLs and deepen the F# clone if it is shallow." } function ConvertTo-PowerShellLiteral { @@ -437,7 +455,10 @@ function Invoke-VSBackportBranchPoint { [switch]$Execute ) + $mode = if ($Execute) { 'Execute' } else { 'Preview' } + Write-Information "[VS backport] $mode for VS '$VSBranch': F# branch '$NewBranchName', fixed minor $VSMinorVersion." -InformationAction Continue $fsharpRepository = Resolve-GitRepository -Path $FSharpRepoPath -Name "F#" + Write-Information "[VS backport] Using F# repository '$fsharpRepository'." -InformationAction Continue $null = Invoke-Git -RepositoryPath $fsharpRepository -Arguments @( "check-ref-format", @@ -467,9 +488,11 @@ function Invoke-VSBackportBranchPoint { $ownedRepository = $null try { + Write-Information '[VS backport] Acquiring Azure DevOps credentials from Azure CLI.' -InformationAction Continue $accessToken = Get-AzureDevOpsToken if ($VSRepoPath) { $vsRepository = Resolve-GitRepository -Path $VSRepoPath -Name "Visual Studio" + Write-Information "[VS backport] Using local VS repository '$vsRepository' without changing its checkout." -InformationAction Continue } else { $VSBranch = $VSBranch -replace '^refs/heads/', '' @@ -478,6 +501,7 @@ function Invoke-VSBackportBranchPoint { $clonePath = Join-Path (Get-Location).Path (".vs-backport-" + [Guid]::NewGuid().ToString('N')) $null = New-Item -ItemType Directory -Path $clonePath $ownedRepository = $clonePath + Write-Information "[VS backport] Cloning VS branch '$VSBranch' into '$ownedRepository' (depth 100, no checkout)." -InformationAction Continue $null = Invoke-Git -RepositoryPath $ownedRepository -AccessToken $accessToken -Arguments @( "clone", "--quiet", "--depth=100", "--single-branch", "--no-tags", "--no-checkout", "--branch", $VSBranch, "https://devdiv.visualstudio.com/DefaultCollection/DevDiv/_git/VS", "." @@ -490,6 +514,7 @@ function Invoke-VSBackportBranchPoint { $VSBranch = $vsBranchInfo.Branch $vsSnapshot = Invoke-Git -RepositoryPath $vsRepository -Arguments @("rev-parse", "$vsBranchRef^{commit}") | Select-Object -First 1 + Write-Information "[VS backport] Reading F# payload from VS snapshot $vsSnapshot; locating its insertion." -InformationAction Continue $componentsPath = ".corext/Configs/components.json" $branchComponentsJson = (Invoke-Git -RepositoryPath $vsRepository -Arguments @( @@ -505,6 +530,7 @@ function Invoke-VSBackportBranchPoint { $insertionCommit = Get-VisualStudioInsertion -RepositoryPath $vsRepository -Snapshot $vsSnapshot ` -PayloadUrl $fsharpComponent.url -Branch $vsBranchInfo -AccessToken $accessToken + Write-Information "[VS backport] Found VS insertion $insertionCommit." -InformationAction Continue $insertionDate = (Invoke-Git -RepositoryPath $vsRepository -Arguments @( "show", "-s", @@ -529,6 +555,7 @@ function Invoke-VSBackportBranchPoint { $sourceBranch = [Uri]::UnescapeDataString($buildNumberMatch.Groups["sourceBranch"].Value) $buildNumber = $buildNumberMatch.Groups["buildNumber"].Value + Write-Information "[VS backport] Looking up fsharp-ci build '$buildNumber' for source branch '$sourceBranch'." -InformationAction Continue $build = Get-FSharpBuild -BuildNumber $buildNumber -SourceBranch $sourceBranch $branchPoint = $build.sourceVersion @@ -540,6 +567,7 @@ function Invoke-VSBackportBranchPoint { -RepositoryPath $fsharpRepository ` -Branch $sourceBranch ` -Commit $branchPoint + Write-Information "[VS backport] Resolved F# source commit $branchPoint on '$fsharpBranchRef'." -InformationAction Continue $branchPointDate = (Invoke-Git -RepositoryPath $fsharpRepository -Arguments @( "show", @@ -561,6 +589,7 @@ function Invoke-VSBackportBranchPoint { $action = "Preview" # Preflight both files at the exact source SHA, even in preview. + Write-Information '[VS backport] Validating pipeline mappings and fixed-minor edits at the branch point.' -InformationAction Continue $branchPointPipelineContent = (Invoke-Git -RepositoryPath $fsharpRepository -Arguments @( "show", "${branchPoint}:azure-pipelines.yml" )) -join "`n" @@ -584,6 +613,7 @@ function Invoke-VSBackportBranchPoint { } } } + Write-Information "[VS backport] Creating and checking out '$NewBranchName' at $branchPoint." -InformationAction Continue $null = Invoke-Git -RepositoryPath $fsharpRepository -Arguments @( "switch", "--no-overwrite-ignore", @@ -594,6 +624,7 @@ function Invoke-VSBackportBranchPoint { try { foreach ($file in @("azure-pipelines.yml", "eng\Versions.props")) { + Write-Information "[VS backport] Updating '$file'." -InformationAction Continue $path = Join-Path $fsharpRepository $file $content = [IO.File]::ReadAllText($path) if ($file -eq "azure-pipelines.yml") { @@ -614,6 +645,12 @@ function Invoke-VSBackportBranchPoint { $action = "Executed" } + if ($Execute) { + Write-Information '[VS backport] Branch and edits are ready; changes are uncommitted. Nothing was pushed or inserted into VS.' -InformationAction Continue + } + else { + Write-Information '[VS backport] Preview complete; no F# files or branches were changed. Use -Execute to apply the planned edits.' -InformationAction Continue + } [PSCustomObject]@{ Action = $action VSBranch = $VSBranch @@ -642,7 +679,10 @@ function Invoke-VSBackportBranchPoint { } } finally { - if ($ownedRepository) { Remove-Item -LiteralPath $ownedRepository -Recurse -Force } + if ($ownedRepository) { + Write-Information "[VS backport] Removing temporary VS repository '$ownedRepository'." -InformationAction Continue + Remove-Item -LiteralPath $ownedRepository -Recurse -Force + } } } From dcbafb6225d94db5a175e564e2077ed6280fac12 Mon Sep 17 00:00:00 2001 From: Tomas Grosup Date: Thu, 8 Oct 2026 13:17:47 +0200 Subject: [PATCH 4/6] Compute VS minor versions before builds Replace the backport utilities and XML calendar with a date-only F# script. Supply the version through existing build entry points, require release pins, and reject unset VS assembly versions without affecting unrelated builds. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- DEVGUIDE.md | 12 + FSharpBuild.Directory.Build.props | 3 +- azure-pipelines-PR.yml | 5 + azure-pipelines.yml | 7 +- eng/Build.ps1 | 26 +- eng/VSMinorVersion.props | 49 -- eng/Versions.props | 9 +- eng/build.sh | 19 + eng/release/README.md | 117 --- eng/release/VSVersion.proj | 6 - .../Get-VSBackportBranchPoint.Tests.ps1 | 324 -------- .../scripts/Get-VSBackportBranchPoint.ps1 | 691 ------------------ eng/release/scripts/VSMinorVersion.Tests.ps1 | 178 ----- eng/scripts/GetVSMinorVersion.fsx | 27 + eng/tests/VSMinorVersion.Tests.fsx | 64 ++ 15 files changed, 153 insertions(+), 1384 deletions(-) delete mode 100644 eng/VSMinorVersion.props delete mode 100644 eng/release/README.md delete mode 100644 eng/release/VSVersion.proj delete mode 100644 eng/release/scripts/Get-VSBackportBranchPoint.Tests.ps1 delete mode 100644 eng/release/scripts/Get-VSBackportBranchPoint.ps1 delete mode 100644 eng/release/scripts/VSMinorVersion.Tests.ps1 create mode 100644 eng/scripts/GetVSMinorVersion.fsx create mode 100644 eng/tests/VSMinorVersion.Tests.fsx diff --git a/DEVGUIDE.md b/DEVGUIDE.md index 0af0c55e85c..6c1a114abcf 100644 --- a/DEVGUIDE.md +++ b/DEVGUIDE.md @@ -365,6 +365,18 @@ As you would expect, doing this requires both Windows and Visual Studio are inst See [Developing on Windows](#Developing-on-Windows) for instructions to install what is needed; it's the same prerequisites. +`Build.cmd` and `build.sh` set `VSMinorVersion` from the UTC date. CI uses the pipeline start date through `VSBuildDateUtc`. +The estimate changes on the Friday before the first Tuesday of each month. September 2025 corresponds to minor 0. +On release branches, replace the conditional `UNSET` in `eng/Versions.props` with an unconditional numeric pin. + +For direct VS-project builds or IDE builds, set the version before starting MSBuild or Visual Studio: + +```powershell +$env:VSMinorVersion = & .\.dotnet\dotnet.exe fsi .\eng\scripts\GetVSMinorVersion.fsx ([DateTime]::UtcNow.ToString('yyyy-MM-dd', [Globalization.CultureInfo]::InvariantCulture)) +``` + +VS builds without a valid version fail. Unrelated compiler, Core, and test projects do not need this property. + ### Quickly see your changes locally First, ensure that `VisualFSharpDebug` is the startup project. diff --git a/FSharpBuild.Directory.Build.props b/FSharpBuild.Directory.Build.props index 733d843fefc..45754dc6b6a 100644 --- a/FSharpBuild.Directory.Build.props +++ b/FSharpBuild.Directory.Build.props @@ -28,6 +28,7 @@ $(ArtifactsDir)\Bootstrap 4.4.0 0025;$(WarningsAsErrors) + $(WarningsAsErrors);2003 $(OtherFlags) --nowarn:3384 $(OtherFlags) --times --nowarn:75 $(OtherFlags) $(AdditionalFscCmdFlags) @@ -80,7 +81,7 @@ - $(NoWarn);FS2003 + $(NoWarn);FS2003 true embedded fs diff --git a/azure-pipelines-PR.yml b/azure-pipelines-PR.yml index 18d1c38f3bd..a9a3ea9c618 100644 --- a/azure-pipelines-PR.yml +++ b/azure-pipelines-PR.yml @@ -51,6 +51,8 @@ pr: - TESTGUIDE.md variables: + - name: VSBuildDateUtc + value: $[ format('{0:yyyy-MM-dd}', pipeline.startTime) ] - name: _TeamName value: FSharp - name: _BuildConfig @@ -268,6 +270,9 @@ stages: NativeToolsOnMachine: true displayName: Build + - script: .dotnet\dotnet.exe fsi eng\tests\VSMinorVersion.Tests.fsx + displayName: Check VS minor versions + - task: PublishTestResults@2 displayName: Publish Test Results inputs: diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 920cbeec59a..8d088489d82 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -25,8 +25,8 @@ trigger: - TESTGUIDE.md variables: - - name: VSBuildTimestampUtc - value: $[ format('{0:yyyy-MM-ddTHH:mm:ss.fffffffzzz}', pipeline.startTime) ] + - name: VSBuildDateUtc + value: $[ format('{0:yyyy-MM-dd}', pipeline.startTime) ] # Release branch for F# # The future "product" gets developed in main, so should be main in main. # When servicing branch gets created, this should maintain the mapping between F# servicing and VS servicing branches @@ -107,7 +107,7 @@ extends: platforms: - name: 'Managed' container: 'mcr.microsoft.com/dotnet-buildtools/prereqs:centos-stream9' - buildArguments: '--source-build /p:VSBuildTimestampUtc=$(VSBuildTimestampUtc)' + buildArguments: '--source-build' enableTelemetry: true helixRepo: dotnet/fsharp jobs: @@ -144,7 +144,6 @@ extends: /p:TeamName=$(_TeamName) /p:DotNetPublishUsingPipelines=$(_PublishUsingPipelines) /p:OfficialBuildId=$(BUILD.BUILDNUMBER) - /p:VSBuildTimestampUtc=$(VSBuildTimestampUtc) /p:VisualStudioDropName=$(VisualStudioDropName) /p:GenerateSbom=true env: diff --git a/eng/Build.ps1 b/eng/Build.ps1 index 21764e9f2c3..b6d76921c99 100644 --- a/eng/Build.ps1 +++ b/eng/Build.ps1 @@ -86,12 +86,6 @@ $ErrorActionPreference = "Stop" if ($ci -and -not $PSBoundParameters.ContainsKey('msbuildMultiThreaded')) { $msbuildMultiThreaded = $false } - -# Capture once so restore, build and pack cannot straddle a scheduled version transition. -if ($official -and -not $env:VSBuildTimestampUtc -and -not ($properties -match '^[-/]p:VSBuildTimestampUtc=')) { - $properties += "/p:VSBuildTimestampUtc=$([DateTime]::UtcNow.ToString('o', [Globalization.CultureInfo]::InvariantCulture))" -} - $BuildCategory = "" $BuildMessage = "" @@ -581,6 +575,26 @@ try { } $buildTool = InitializeBuildTool + $dotnetExe = Join-Path (InitializeDotNetCli -install:$restore) (GetExecutableFileName 'dotnet') + $vsMinorVersion = ([xml](Get-Content "$RepoRoot/eng/Versions.props" -Raw)).SelectSingleNode('/Project/PropertyGroup/VSMinorVersion').InnerText + $branch = if ($env:SYSTEM_PULLREQUEST_TARGETBRANCH) { $env:SYSTEM_PULLREQUEST_TARGETBRANCH } + elseif ($env:BUILD_SOURCEBRANCH) { $env:BUILD_SOURCEBRANCH } + else { & git -C $RepoRoot branch --show-current } + if ($branch -match '^(refs/heads/)?release/') { + if ($vsMinorVersion -notmatch '^\d+$') { + throw "Release branches must pin VSMinorVersion in eng/Versions.props." + } + $override = $properties -match '^/p:VSMinorVersion=' + if ($override -and ($override -ne "/p:VSMinorVersion=$vsMinorVersion")) { + throw "VSMinorVersion must match the release branch's pin ($vsMinorVersion)." + } + } + if ($vsMinorVersion -eq 'UNSET') { + $date = if ($env:VSBUILDDATEUTC) { $env:VSBUILDDATEUTC } else { [DateTime]::UtcNow.ToString('yyyy-MM-dd', [Globalization.CultureInfo]::InvariantCulture) } + $vsMinorVersion = & $dotnetExe fsi "$PSScriptRoot/scripts/GetVSMinorVersion.fsx" $date + if ($LASTEXITCODE -ne 0) { throw "VS minor version calculation failed." } + } + $env:VSMinorVersion = "$vsMinorVersion" $toolsetBuildProj = InitializeToolset TryDownloadDotnetFrameworkSdk diff --git a/eng/VSMinorVersion.props b/eng/VSMinorVersion.props deleted file mode 100644 index e2acc39a1f0..00000000000 --- a/eng/VSMinorVersion.props +++ /dev/null @@ -1,49 +0,0 @@ - - - - false - - - <_VSOffsetTimestamp>$([System.Text.RegularExpressions.Regex]::Replace('$(VSBuildTimestampUtc)', 'Z$', '+00:00')) - <_VSBuildTicks>$([System.DateTime]::ParseExact('$(_VSOffsetTimestamp)', 'yyyy-MM-ddTHH:mm:ss.FFFFFFFzzz', $([System.Globalization.CultureInfo]::GetCultureInfo(''))).ToUniversalTime().Ticks) - - <_VSMinorBaseYear>2026 - <_VSMinorBaseMonth>10 - <_VSMinorBase>13 - - <_VSNominalMonth>$([System.DateTime]::new($(_VSBuildTicks)).AddDays(10).ToString('yyyy-MM-01', $([System.Globalization.CultureInfo]::GetCultureInfo('')))) - <_VSMonthStart>$([System.DateTime]::ParseExact('$(_VSNominalMonth)', 'yyyy-MM-dd', $([System.Globalization.CultureInfo]::GetCultureInfo('')))) - <_VSFirstWeekday>$([System.Convert]::ToInt32($([System.DateTime]::Parse('$(_VSMonthStart)').DayOfWeek))) - <_VSPatchTuesdayOffset>$([MSBuild]::Add(7, $([MSBuild]::Modulo($([MSBuild]::Subtract(9, $(_VSFirstWeekday))), 7)))) - <_VSNominalSnap>$([System.DateTime]::Parse('$(_VSMonthStart)').AddDays($(_VSPatchTuesdayOffset)).AddDays(-11).AddHours(12)) - - <_VSSnapYear>$([System.DateTime]::Parse('$(_VSNominalSnap)').Year) - <_VSMarchWeekday>$([System.Convert]::ToInt32($([System.DateTime]::new($(_VSSnapYear), 3, 1).DayOfWeek))) - <_VSNovemberWeekday>$([System.Convert]::ToInt32($([System.DateTime]::new($(_VSSnapYear), 11, 1).DayOfWeek))) - <_VSDaylightStart>$([System.DateTime]::new($(_VSSnapYear), 3, 1).AddDays($([MSBuild]::Add(7, $([MSBuild]::Modulo($([MSBuild]::Subtract(7, $(_VSMarchWeekday))), 7))))).Ticks) - <_VSDaylightEnd>$([System.DateTime]::new($(_VSSnapYear), 11, 1).AddDays($([MSBuild]::Modulo($([MSBuild]::Subtract(7, $(_VSNovemberWeekday))), 7))).Ticks) - <_VSPacificOffset>8 - <_VSPacificOffset Condition="$([System.DateTime]::Parse('$(_VSNominalSnap)').Ticks) >= $(_VSDaylightStart) and $([System.DateTime]::Parse('$(_VSNominalSnap)').Ticks) < $(_VSDaylightEnd)">7 - <_VSNominalTicks>$([System.DateTime]::Parse('$(_VSNominalSnap)').AddHours($(_VSPacificOffset)).AddHours(24).Ticks) - <_VSMonthDifference>$([MSBuild]::Add($([MSBuild]::Multiply($([MSBuild]::Subtract($([System.DateTime]::Parse('$(_VSMonthStart)').Year), $(_VSMinorBaseYear))), 12)), $([MSBuild]::Subtract($([System.DateTime]::Parse('$(_VSMonthStart)').Month), $(_VSMinorBaseMonth))))) - - <_VSMonthDifference Condition="$([System.Int64]::Parse('$(_VSBuildTicks)').CompareTo($([System.Int64]::Parse('$(_VSNominalTicks)')))) < 0">$([MSBuild]::Subtract($(_VSMonthDifference), 1)) - <_VSSelectedMinor>$([MSBuild]::Add($(_VSMinorBase), $(_VSMonthDifference))) - <_VSValidationError /> - <_VSValidationError Condition="'$(VSMajorVersion)' != '18'">The VS minor heuristic covers major 18 only. Update the version-number calibration. - <_VSValidationError Condition="$(_VSSelectedMinor) < 0">The computed VS minor is below 0. Check VSBuildTimestampUtc and the version-number calibration. - <_VSValidationError Condition="$(_VSSelectedMinor) > 65534">The computed VS minor exceeds 65534. Check VSBuildTimestampUtc and the version-number calibration. - - <_VSValidationResult Condition="'$(_VSValidationError)' != ''">$([System.Int32]::Parse('$(_VSValidationError)')) - $(_VSSelectedMinor) - true - $(MSBuildWarningsNotAsErrors);FSVS1001 - - - - - - diff --git a/eng/Versions.props b/eng/Versions.props index 6fec6fdbc1f..18187713a6a 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -60,15 +60,8 @@ - 18 - 8 - true - - - - - + UNSET $(VSMajorVersion).0 $(VSMajorVersion).$(VSMinorVersion).0 $(VSAssemblyVersionPrefix).0 diff --git a/eng/build.sh b/eng/build.sh index 2af459b621e..608a83760ef 100755 --- a/eng/build.sh +++ b/eng/build.sh @@ -387,6 +387,25 @@ InitializeDotNetCli $restore # Apphosts (bootstrap fsc, testhost, etc.) resolve runtimes via DOTNET_ROOT, not PATH. export DOTNET_ROOT="$DOTNET_INSTALL_DIR" +vs_minor=$(VSMinorVersion= "$DOTNET_INSTALL_DIR/dotnet" msbuild "$scriptroot/Versions.props" -nologo -getProperty:VSMinorVersion) || ExitWithExitCode $? +branch=${SYSTEM_PULLREQUEST_TARGETBRANCH:-${BUILD_SOURCEBRANCH:-$(git -C "$repo_root" branch --show-current)}} +if [[ "$branch" =~ ^(refs/heads/)?release/ ]]; then + if [[ ! "$vs_minor" =~ ^[0-9]+$ ]]; then + echo "Release branches must pin VSMinorVersion in eng/Versions.props." >&2 + ExitWithExitCode 1 + fi + for property in ${properties[@]+"${properties[@]}"}; do + if [[ "$(echo "$property" | tr '[:upper:]' '[:lower:]')" == /p:vsminorversion=* && "${property#*=}" != "$vs_minor" ]]; then + echo "VSMinorVersion must match the release branch's pin ($vs_minor)." >&2 + ExitWithExitCode 1 + fi + done +fi +if [[ "$vs_minor" == UNSET ]]; then + vs_minor=$("$DOTNET_INSTALL_DIR/dotnet" fsi "$scriptroot/scripts/GetVSMinorVersion.fsx" "${VSBUILDDATEUTC:-$(date -u +%F)}") || ExitWithExitCode $? +fi +export VSMinorVersion="$vs_minor" + # Resolve product TFM from centralized source of truth if not overridden via --tfm if [[ "$tfm" == "" ]]; then tfm=$("$DOTNET_INSTALL_DIR/dotnet" msbuild "$scriptroot/TargetFrameworks.props" -getProperty:FSharpNetCoreProductTargetFramework 2>/dev/null | tr -d '[:space:]') diff --git a/eng/release/README.md b/eng/release/README.md deleted file mode 100644 index f69cbf7bbf3..00000000000 --- a/eng/release/README.md +++ /dev/null @@ -1,117 +0,0 @@ -# Visual Studio backport branches - -`scripts\Get-VSBackportBranchPoint.ps1` finds the F# source commit that produced -the payload currently inserted into a VS branch. It can create an F# backport -branch, configure its insertion target, and pin its VS minor version. - -## Prerequisites - -- Git, PowerShell, and an authenticated Azure CLI (`az login`). -- Permission to read DevDiv's VS repository and dnceng/internal F# build metadata. - Signing in alone does not grant either permission. -- A local F# clone with an `upstream` or `origin` remote containing the source - branch, or with the source commit already available on its local refs. - -## Preview and create a branch - -Supply the minor version the target VS branch will release. It is not inferred -from the branch name or from the version of the currently inserted F# payload. - -```powershell -.\eng\release\scripts\Get-VSBackportBranchPoint.ps1 ` - -VSBranch rel/insiders ` - -FSharpRepoPath Q:\source\fsharp ` - -NewBranchName release/dev18.11 ` - -VSMinorVersion 11 -``` - -The default is a preview. Add `-Execute` to create and check out the F# branch, -update `FSharpReleaseBranchName` and `VSInsertionTargetBranchName` in -`azure-pipelines.yml`, and fix `VSMinorVersion` in `eng\Versions.props`. -The script does not commit, push, or insert anything into VS. Execution requires -a clean tracked F# worktree and must not overwrite untracked files. - -Optionally supply `-VSRepoPath Q:\source\VS`. Without it, the script uses an -authenticated shallow VS clone in an owned `.vs-backport-` directory beneath -the invocation's working directory, and removes it when finished, including on -failure. Initial depth is 100 commits; it deepens by 100 commits up to ten times. -If the history limit is reached, supply a clone with sufficient history. Preview -can fetch refs and use temporary disk space but does not edit the F# worktree. - -If the inserted build's source commit is absent from cached F# refs, the script -fetches that source branch from configured `upstream`, then `origin` if needed. -This updates remote-tracking refs without changing your checkout or local edits. -Fetch failures are reported; check the remote URL and repository permissions. -If a shallow F# clone still lacks the source commit, deepen it and retry. - -Progress messages appear by default on PowerShell's information stream (6), -covering authentication, clone/fetch/deepening, build lookup, validation, edits, -and cleanup. The final structured result is the only success-stream output, so -assigning it to a variable or piping it to `ConvertTo-Json` still works. -Use `6>$null` to hide progress, or `6>progress.log` to capture it separately. -Credentials and authorization headers are not logged. - -The caller is responsible for choosing the correct release minor. Two VS release -branches can legitimately target the same minor. A backport has -`UseVSScheduledMinorVersion=false`, so later builds retain the supplied minor even -when they receive a build timestamp. - -## Main's automatic minor version - -`eng\VSMinorVersion.props` selects `VSMinorVersion` during MSBuild evaluation when -`UseVSScheduledMinorVersion=true` and `VSBuildTimestampUtc` is supplied. -Official builds supply one timestamp across restore, build, and pack. Local -builds without an explicit timestamp retain the literal minor in -`eng\Versions.props`. - -The nominal main-to-insiders snap is **Patch Tuesday (the second Tuesday of each -month) minus 11 calendar days**, at noon Pacific. The minor changes **24 elapsed -hours later**. All snap dates are calculated; there is no release schedule table. -Pacific daylight-saving time is taken into account when converting to UTC. - -To reproduce a selection without compiling: - -```powershell -dotnet msbuild eng\release\VSVersion.proj -t:PrintVSVersion ` - -p:VSBuildTimestampUtc=2026-09-11T12:00:00Z -``` - -This selects minor 12. Use the same property on a build command to override the -timestamp. Timestamps must include `Z` or a numeric UTC offset. Version selection -needs no network access. - -Minor numbering uses a single calibration: October 2026's Patch Tuesday cycle -maps to minor 13. Each earlier/later month decrements/increments that number once. -This reference establishes version numbers, not snap dates; it requires no -monthly updates. The snap can fall in the preceding calendar month. -Automatic selections emit advisory warning `FSVS1001`, including when general -MSBuild warnings are treated as errors, because the heuristic cannot confirm the -actual state of the VS branches. - -Input validation also runs during evaluation-only queries such as `-getProperty`: -missing offsets, unsupported majors, and out-of-range minors fail rather than -falling back to a literal version. These queries do not execute warning targets; -read `VSMinorVersionIsHeuristic` to distinguish an automatic estimate (`true`) -from the local fallback or a fixed backport minor (`false`). - -Estimates do not account for holidays or rescheduled snaps and may run ahead of -or behind actual VS development. Pacific conversion -uses the US DST rule: second Sunday in March through first Sunday in November; -update it if that rule changes. The calibration covers VS major 18; changing the -major or minor numbering convention requires updating `_VSMinorBaseYear`, -`_VSMinorBaseMonth`, and `_VSMinorBase` in `eng\VSMinorVersion.props`. Computed -minors outside 0..65534 are rejected. - -For reproducibility, retain both the source revision (including its calibration) -and the build timestamp. Do not add raw private schedule documents to the repo. - -## Tests - -Run the PowerShell tests using the existing Pester installation: - -```powershell -Invoke-Pester eng\release\scripts -``` - -The tests use local Git fixtures and MSBuild evaluation; no VS insertion is -performed. diff --git a/eng/release/VSVersion.proj b/eng/release/VSVersion.proj deleted file mode 100644 index b58deecf130..00000000000 --- a/eng/release/VSVersion.proj +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/eng/release/scripts/Get-VSBackportBranchPoint.Tests.ps1 b/eng/release/scripts/Get-VSBackportBranchPoint.Tests.ps1 deleted file mode 100644 index 7a24a37d0b2..00000000000 --- a/eng/release/scripts/Get-VSBackportBranchPoint.Tests.ps1 +++ /dev/null @@ -1,324 +0,0 @@ -# Compatible with the repository's Pester 3.x runner. -# Invoke-Pester .\eng\release\scripts\Get-VSBackportBranchPoint.Tests.ps1 -. "$PSScriptRoot\Get-VSBackportBranchPoint.ps1" -VSBranch fixture -FSharpRepoPath fixture -NewBranchName fixture -VSMinorVersion 0 -$script:realGit = ${function:Invoke-Git} -$script:realSetMinor = ${function:Set-FixedVSMinorVersion} - -Describe 'Fixed VS minor editing' { - $literal = @' - - - 18 - 8 - keep - - -'@ - - It 'accepts zero and the maximum component, preserving unrelated content' { - foreach ($minor in @('0', '65534')) { - $result = Set-FixedVSMinorVersion $literal $minor - $result | Should Match "$minor" - $result | Should Match 'false' - $result | Should Match '18' - $result | Should Match 'keep' - } - } - - It 'rejects invalid and fractional inputs before numeric coercion' { - foreach ($minor in @('-1', '65535', '1.5', '1e1', ' 11', 'NaN', '')) { - { Invoke-VSBackportBranchPoint -VSBranch fixture -FSharpRepoPath fixture -NewBranchName fixture -VSMinorVersion $minor } | - Should Throw 'VSMinorVersion' - } - { Invoke-VSBackportBranchPoint -VSBranch fixture -FSharpRepoPath fixture -NewBranchName fixture -VSMinorVersion 1.5 } | - Should Throw 'VSMinorVersion' - } - - It 'allows the same minor on multiple branches and preserves CRLF' { - $content = $literal.Replace("`r`n", "`n").Replace("`n", "`r`n") - $result = Set-FixedVSMinorVersion $content 8 - (Set-FixedVSMinorVersion $result 8) | Should Be $result - $result.Replace("`r`n", '') | Should Not Match "`n" - } - - It 'replaces a conditional scheduled default before its import' { - $content = $literal.Replace('keep', @' -true -'@).Replace('', ' ') - $result = Set-FixedVSMinorVersion $content 11 - $result | Should Match '11' - $result.IndexOf('false') | Should BeLessThan $result.IndexOf('8', ''), - $literal.Replace('8', '89'), - $literal.Replace('', ''), - $literal.Replace('8', '$(Computed)'), - $literal.Replace('', '') - )) { - { Set-FixedVSMinorVersion $content 11 } | Should Throw 'eng\Versions.props' - } - } - - It 'freezes the actual Versions.props when a later build timestamp is supplied' { - $eng = Join-Path $PSScriptRoot '..\..' - $versions = Set-FixedVSMinorVersion ([IO.File]::ReadAllText((Join-Path $eng 'Versions.props'))) 11 - $path = Join-Path $TestDrive 'Versions.props' - [IO.File]::WriteAllText($path, $versions) - Copy-Item (Join-Path $eng 'Version.Details.props') $TestDrive - Copy-Item (Join-Path $eng 'VSMinorVersion.props') $TestDrive - $output = & dotnet msbuild $path -nologo -t:ValidateVSScheduledMinorVersion ` - -p:VSBuildTimestampUtc=2026-12-02T19:00:00Z -getProperty:VSMinorVersion,UseVSScheduledMinorVersion,VSAssemblyVersion - $LASTEXITCODE | Should Be 0 - $properties = (($output -join "`n") | ConvertFrom-Json).Properties - $properties.VSMinorVersion | Should Be '11' - $properties.UseVSScheduledMinorVersion | Should Be 'false' - $properties.VSAssemblyVersion | Should Be '18.11.0.0' - } -} - -Describe 'Backport Git fixtures' { - BeforeEach { - $script:fixtureRoot = Join-Path (Get-Location).Path ('.vs-backport-tests-' + [Guid]::NewGuid().ToString('N')) - $script:fsRepo = Join-Path $fixtureRoot 'fsharp' - $script:vsRepo = Join-Path $fixtureRoot 'vs' - foreach ($repo in @($fsRepo, $vsRepo)) { - $null = New-Item -ItemType Directory -Path $repo -Force - $null = Invoke-Git $repo @('init', '--quiet', '--initial-branch=main') - $null = Invoke-Git $repo @('config', 'user.name', 'Fixture') - $null = Invoke-Git $repo @('config', 'user.email', 'fixture@example.invalid') - $null = Invoke-Git $repo @('config', 'core.autocrlf', 'false') - $null = Invoke-Git $repo @('config', 'commit.gpgsign', 'false') - } - $null = New-Item -ItemType Directory -Path "$fsRepo\eng" - [IO.File]::WriteAllText("$fsRepo\azure-pipelines.yml", "variables:`r`n- name: FSharpReleaseBranchName`r`n value: main`r`n- name: VSInsertionTargetBranchName`r`n value: main`r`n") - [IO.File]::WriteAllText("$fsRepo\eng\Versions.props", "`r`n `r`n 18`r`n 8`r`n `r`n`r`n") - $null = Invoke-Git $fsRepo @('add', '.') - $null = Invoke-Git $fsRepo @('commit', '--quiet', '-m', 'source') - $script:source = Invoke-Git $fsRepo @('rev-parse', 'HEAD') - $null = New-Item -ItemType Directory -Path "$vsRepo\.corext\Configs" -Force - $script:payload = 'https://example.invalid/dotnet-fsharp/main/20260911.1;payload' - [IO.File]::WriteAllText("$vsRepo\.corext\Configs\components.json", '{"Components":{"Microsoft.FSharp":{"url":"old"}}}') - $null = Invoke-Git $vsRepo @('add', '.') - $null = Invoke-Git $vsRepo @('commit', '--quiet', '-m', 'old payload') - [IO.File]::WriteAllText("$vsRepo\.corext\Configs\components.json", ('{"Components":{"Microsoft.FSharp":{"url":"' + $payload + '","version":"1"}}}')) - $null = Invoke-Git $vsRepo @('commit', '--quiet', '-am', 'insert payload') - $script:insertion = Invoke-Git $vsRepo @('rev-parse', 'HEAD') - 1..3 | ForEach-Object { $null = Invoke-Git $vsRepo @('commit', '--quiet', '--allow-empty', '-m', "later $_") } - $script:arguments = @{ VSBranch = 'refs/heads/main'; VSRepoPath = $vsRepo; FSharpRepoPath = $fsRepo; NewBranchName = 'release/fixture'; VSMinorVersion = '11' } - Mock Get-AzureDevOpsToken { 'fixture-only-token' } - Mock Get-FSharpBuild { - param($BuildNumber, $SourceBranch) - if ($BuildNumber -ne '20260911.1' -or $SourceBranch -ne 'main') { throw 'Incorrect build lookup' } - [PSCustomObject]@{ sourceVersion = $script:source; id = 42 } - } - } - - AfterEach { - Remove-Item -LiteralPath $fixtureRoot -Recurse -Force - } - - It 'previews the exact source and edits without changing either checkout' { - $vsHead = Invoke-Git $vsRepo @('rev-parse', 'HEAD') - $result = Invoke-VSBackportBranchPoint @arguments - $result.Action | Should Be 'Preview' - $result.VSBranch | Should Be 'main' - $result.VSSnapshot | Should Be $vsHead - $result.VSInsertionCommit | Should Be $insertion - $result.FSharpBranchPoint | Should Be $source - $result.VSMinorVersion | Should Be 11 - $result.PlannedEdits.Count | Should Be 2 - (Invoke-Git $fsRepo @('status', '--porcelain')) | Should BeNullOrEmpty - (Test-GitRef $fsRepo 'refs/heads/release/fixture') | Should Be $false - (Invoke-Git $vsRepo @('rev-parse', 'HEAD')) | Should Be $vsHead - } - - It 'reports visible progress separately from the structured result without credentials' { - $output = @(Invoke-VSBackportBranchPoint @arguments 6>&1) - $progress = @($output | Where-Object { $_ -is [System.Management.Automation.InformationRecord] }) - $results = @($output | Where-Object { $_ -isnot [System.Management.Automation.InformationRecord] }) - $results.Count | Should Be 1 - $results[0].Action | Should Be 'Preview' - $messages = ($progress | ForEach-Object { $_.MessageData }) -join "`n" - $messages | Should Match 'Acquiring Azure DevOps credentials' - $messages | Should Match 'Looking up fsharp-ci build' - $messages | Should Match 'Validating pipeline mappings' - $messages | Should Match 'Preview complete' - $messages | Should Not Match 'fixture-only-token|Authorization' - } - - It 'executes only in the fixture, preserving newlines and leaving uncommitted edits' { - $result = Invoke-VSBackportBranchPoint @arguments -Execute - $result.Action | Should Be 'Executed' - (Invoke-Git $fsRepo @('branch', '--show-current')) | Should Be 'release/fixture' - (Invoke-Git $fsRepo @('rev-parse', 'HEAD')) | Should Be $source - $content = [IO.File]::ReadAllText("$fsRepo\eng\Versions.props") - $content | Should Match '11' - $content | Should Match 'false' - $content.Replace("`r`n", '') | Should Not Match "`n" - ([IO.File]::ReadAllText("$fsRepo\azure-pipelines.yml")) | Should Match 'value: release/fixture' - @(Invoke-Git $fsRepo @('status', '--porcelain')).Count | Should Be 2 - } - - It 'rejects dirty worktrees before creating a branch' { - Add-Content "$fsRepo\azure-pipelines.yml" '# dirty' - { Invoke-VSBackportBranchPoint @arguments -Execute } | Should Throw 'tracked working-tree changes' - (Test-GitRef $fsRepo 'refs/heads/release/fixture') | Should Be $false - } - - It 'fetches a missing F# source commit without changing checkout or edits' { - $publisher = Join-Path $fixtureRoot 'publisher' - $null = Invoke-Git $fixtureRoot @('clone', '--quiet', $fsRepo, $publisher) - $null = Invoke-Git $publisher @('config', 'user.name', 'Fixture') - $null = Invoke-Git $publisher @('config', 'user.email', 'fixture@example.invalid') - $null = Invoke-Git $publisher @('config', 'commit.gpgsign', 'false') - $null = Invoke-Git $publisher @('commit', '--quiet', '--allow-empty', '-m', 'new inserted source') - $newSource = Invoke-Git $publisher @('rev-parse', 'HEAD') - $null = Invoke-Git $fsRepo @('remote', 'add', 'upstream', $publisher) - [IO.File]::AppendAllText("$fsRepo\azure-pipelines.yml", '# local edit') - $head = Invoke-Git $fsRepo @('rev-parse', 'HEAD') - $content = [IO.File]::ReadAllText("$fsRepo\azure-pipelines.yml") - (Test-GitRef $fsRepo $newSource) | Should Be $false - (Resolve-FSharpBranch $fsRepo main $newSource) | Should Be 'refs/remotes/upstream/main' - (Test-GitRef $fsRepo $newSource) | Should Be $true - (Invoke-Git $fsRepo @('rev-parse', 'HEAD')) | Should Be $head - ([IO.File]::ReadAllText("$fsRepo\azure-pipelines.yml")) | Should Be $content - } - - It 'does not fetch when a cached F# ref contains the source commit' { - $null = Invoke-Git $fsRepo @('remote', 'add', 'upstream', (Join-Path $fixtureRoot 'nonexistent-remote')) - (Resolve-FSharpBranch $fsRepo main $source) | Should Be 'main' - } - - It 'rejects existing and invalid branch names' { - foreach ($name in @('main', 'release/invalid name')) { - $arguments.NewBranchName = $name - { Invoke-VSBackportBranchPoint @arguments -Execute } | Should Throw 'branch' - } - (Invoke-Git $fsRepo @('branch', '--show-current')) | Should Be 'main' - } - - It 'preflights malformed pipeline mappings and version layouts' { - foreach ($file in @('azure-pipelines.yml', 'eng\Versions.props')) { - $content = [IO.File]::ReadAllText((Join-Path $fsRepo $file)) - [IO.File]::WriteAllText((Join-Path $fsRepo $file), $content.Replace('VSInsertionTargetBranchName', 'Missing').Replace('8', '$(Computed)')) - $null = Invoke-Git $fsRepo @('commit', '--quiet', '-am', 'unsupported file') - $script:source = Invoke-Git $fsRepo @('rev-parse', 'HEAD') - { Invoke-VSBackportBranchPoint @arguments -Execute } | Should Throw 'Expected one' - (Test-GitRef $fsRepo 'refs/heads/release/fixture') | Should Be $false - $null = Invoke-Git $fsRepo @('revert', '--no-edit', 'HEAD') - } - } - - It 'preflights the source SHA rather than a repaired current file' { - $null = Invoke-Git $fsRepo @('rm', '--quiet', 'eng/Versions.props') - $null = Invoke-Git $fsRepo @('commit', '--quiet', '-m', 'missing versions') - $script:source = Invoke-Git $fsRepo @('rev-parse', 'HEAD') - $null = Invoke-Git $fsRepo @('revert', '--no-edit', 'HEAD') - { Invoke-VSBackportBranchPoint @arguments -Execute } | Should Throw 'git' - { Invoke-VSBackportBranchPoint @arguments } | Should Throw 'git' - (Test-GitRef $fsRepo 'refs/heads/release/fixture') | Should Be $false - } - - It 'rejects ignored file collisions before switching branches' { - $null = Invoke-Git $fsRepo @('rm', '--quiet', 'eng/Versions.props') - $null = Invoke-Git $fsRepo @('commit', '--quiet', '-m', 'remove versions') - $null = New-Item -ItemType Directory -Path "$fsRepo\eng" -Force - [IO.File]::WriteAllText("$fsRepo\eng\Versions.props", 'user content') - [IO.File]::WriteAllText("$fsRepo\.git\info\exclude", 'eng/Versions.props') - { Invoke-VSBackportBranchPoint @arguments -Execute } | Should Throw 'collides' - (Test-GitRef $fsRepo 'refs/heads/release/fixture') | Should Be $false - ([IO.File]::ReadAllText("$fsRepo\eng\Versions.props")) | Should Be 'user content' - } - - It 'rejects a shallow false introduction and deepens to the real insertion' { - $clone = Join-Path $fixtureRoot 'shallow' - $url = ([Uri]($vsRepo + '\')).AbsoluteUri.TrimEnd('/') - $null = Invoke-Git $fixtureRoot @('clone', '--quiet', '--depth=1', '--no-checkout', $url, $clone) - $branch = Resolve-VisualStudioBranch $clone main - $snapshot = Invoke-Git $clone @('rev-parse', 'HEAD') - { Get-VisualStudioInsertion $clone $snapshot $payload $branch -MaxDeepenAttempts 0 } | Should Throw 'Insufficient' - { Get-VisualStudioInsertion $clone $snapshot $payload $branch -DeepenBy 1 -MaxDeepenAttempts 1 } | Should Throw 'Insufficient' - (Get-VisualStudioInsertion $clone $snapshot $payload $branch -DeepenBy 2 -MaxDeepenAttempts 3) | Should Be $insertion - (Resolve-VisualStudioBranch $clone 'refs/remotes/origin/main').Branch | Should Be 'main' - (Test-Path "$clone\.corext") | Should Be $false - (Invoke-Git $clone @('rev-parse', 'HEAD')) | Should Be $snapshot - } - - It 'uses and cleans an owned no-checkout clone without live services' { - Mock Get-AzureDevOpsToken { 'fixture-only-token' } - Mock Invoke-Git { - param($RepositoryPath, $Arguments, $AccessToken) - if ($Arguments[0] -eq 'clone') { - $script:ownedClone = $RepositoryPath - $Arguments = @($Arguments | ForEach-Object { - if ($_ -eq 'https://devdiv.visualstudio.com/DefaultCollection/DevDiv/_git/VS') { - ([Uri]($script:vsRepo + '\')).AbsoluteUri.TrimEnd('/') - } else { $_ } - }) - } - & $script:realGit -RepositoryPath $RepositoryPath -Arguments $Arguments -AccessToken $AccessToken - } - $arguments.Remove('VSRepoPath') - $result = Invoke-VSBackportBranchPoint @arguments - $result.VSInsertionCommit | Should Be $insertion - $result.FSharpBranchPoint | Should Be $source - (Test-Path -LiteralPath $ownedClone) | Should Be $false - (Test-Path -LiteralPath $vsRepo) | Should Be $true - } - - It 'cleans only its owned clone on authentication or clone failure' { - Mock Get-AzureDevOpsToken { 'fixture-only-token' } - Mock Invoke-Git { - param($RepositoryPath, $Arguments, $AccessToken) - if ($Arguments[0] -eq 'clone') { - $script:ownedClone = $RepositoryPath - throw 'Fixture clone denied' - } - & $script:realGit -RepositoryPath $RepositoryPath -Arguments $Arguments -AccessToken $AccessToken - } - $arguments.Remove('VSRepoPath') - { Invoke-VSBackportBranchPoint @arguments } | Should Throw 'Fixture clone denied' - (Test-Path -LiteralPath $ownedClone) | Should Be $false - (Test-Path -LiteralPath $vsRepo) | Should Be $true - } - - It 'restores process authentication settings and never persists the header' { - $old = $env:GIT_CONFIG_COUNT - try { - $env:GIT_CONFIG_COUNT = '0' - $null = Invoke-Git $vsRepo @('status', '--porcelain') -AccessToken 'fixture-only-token' - $env:GIT_CONFIG_COUNT | Should Be '0' - ([IO.File]::ReadAllText("$vsRepo\.git\config")) | Should Not Match 'fixture-only-token|extraHeader' - { Invoke-Git $vsRepo @('invalid-command') -AccessToken 'fixture-only-token' } | - Should Throw 'Authenticated Git operation failed' - $env:GIT_CONFIG_COUNT | Should Be '0' - } - finally { $env:GIT_CONFIG_COUNT = $old } - } - - It 'fails authentication before creating an owned clone' { - Mock Get-AzureDevOpsToken { throw 'Fixture authentication denied' } - $arguments.Remove('VSRepoPath') - { Invoke-VSBackportBranchPoint @arguments } | Should Throw 'Fixture authentication denied' - (Test-Path -LiteralPath $vsRepo) | Should Be $true - (Invoke-Git $fsRepo @('status', '--porcelain')) | Should BeNullOrEmpty - } - - It 'reports partial writes without rolling back the created branch' { - Mock Set-FixedVSMinorVersion { - param($Content, $Minor) - if ((Invoke-Git $script:fsRepo @('branch', '--show-current')) -eq 'release/fixture') { - throw 'Fixture write denied' - } - & $script:realSetMinor $Content $Minor - } - { Invoke-VSBackportBranchPoint @arguments -Execute } | Should Throw 'may be partial' - (Test-GitRef $fsRepo 'refs/heads/release/fixture') | Should Be $true - ([IO.File]::ReadAllText("$fsRepo\azure-pipelines.yml")) | Should Match 'value: release/fixture' - ([IO.File]::ReadAllText("$fsRepo\eng\Versions.props")) | Should Match '8' - } -} diff --git a/eng/release/scripts/Get-VSBackportBranchPoint.ps1 b/eng/release/scripts/Get-VSBackportBranchPoint.ps1 deleted file mode 100644 index d2145f61643..00000000000 --- a/eng/release/scripts/Get-VSBackportBranchPoint.ps1 +++ /dev/null @@ -1,691 +0,0 @@ -<# -.SYNOPSIS - Finds the F# source commit corresponding to the latest F# insertion on a Visual Studio branch. - -.DESCRIPTION - Refreshes the Visual Studio branch's configured upstream, finds the first-parent commit that - introduced its current F# payload, and queries Azure DevOps pipeline definition 499 for the - payload build's source commit. - - Without VSRepoPath, uses an owned shallow, no-checkout clone, deepening at most ten times - by 100 commits. The clone is removed even on failure. Requires Azure CLI authentication - with access to DevDiv/VS and dnceng/internal. - - By default, reports the command and proposed pipeline/version edits without changing F#. - -Execute creates and checks out the branch, updates azure-pipelines.yml, and fixes the - caller-supplied minor in eng\Versions.props. It does not commit, push, or insert into VS. - Progress is displayed on the information stream (6); the final result remains on stdout. - -.PARAMETER VSBranch - The Visual Studio branch whose latest F# insertion should be located. - -.PARAMETER VSRepoPath - Optional local Visual Studio clone. Its checkout is not changed. Otherwise a temporary - clone of https://devdiv.visualstudio.com/DefaultCollection/DevDiv/_git/VS is used. - -.PARAMETER FSharpRepoPath - The path to a local clone of the dotnet/fsharp repository. - -.PARAMETER NewBranchName - The name of the F# backport branch to create. - -.PARAMETER VSMinorVersion - Required integer from 0 through 65534 for the target VS release. The caller owns this - mapping; different branches may use the same minor. Scheduled minor selection is disabled. - -.PARAMETER Execute - Creates the branch and updates both files. Without this switch, no F# changes are made. - -.EXAMPLE - .\Get-VSBackportBranchPoint.ps1 ` - -VSBranch rel/insiders ` - -VSRepoPath Q:\source\VS ` - -FSharpRepoPath Q:\source\fsharp ` - -NewBranchName release/dev18.11 -VSMinorVersion 11 - -.EXAMPLE - .\Get-VSBackportBranchPoint.ps1 ` - -VSBranch rel/insiders ` - -VSRepoPath Q:\source\VS ` - -FSharpRepoPath Q:\source\fsharp ` - -NewBranchName release/dev18.11 -VSMinorVersion 11 ` - -Execute -#> - -[CmdletBinding(PositionalBinding = $false)] -param ( - [Parameter(Mandatory = $true)] - [string]$VSBranch, - - [string]$VSRepoPath, - - [Parameter(Mandatory = $true)] - [string]$FSharpRepoPath, - - [Parameter(Mandatory = $true)] - [string]$NewBranchName, - - [Parameter(Mandatory = $true)] - [ValidateScript({ $_ -cmatch '^[0-9]{1,5}$' -and [int]$_ -le 65534 })] - [string]$VSMinorVersion, - - [switch]$Execute -) - -Set-StrictMode -Version 2.0 -$ErrorActionPreference = "Stop" - -function Invoke-Git { - param ( - [Parameter(Mandatory = $true)] - [string]$RepositoryPath, - - [Parameter(Mandatory = $true)] - [string[]]$Arguments, - - [string]$AccessToken - ) - - $savedEnvironment = @{} - try { - if ($AccessToken) { - # Git's process configuration keeps the header out of argv and repository config. - $environment = @{ - GIT_CONFIG_COUNT = '1' - GIT_CONFIG_KEY_0 = 'http.extraHeader' - GIT_CONFIG_VALUE_0 = "Authorization: Bearer $AccessToken" - GIT_TERMINAL_PROMPT = '0' - GIT_TRACE = '0' - GIT_TRACE_CURL = '0' - GIT_CURL_VERBOSE = '0' - GIT_TRACE_PACKET = '0' - GIT_TRACE2 = '0' - GIT_TRACE2_EVENT = '0' - GIT_TRACE2_PERF = '0' - } - foreach ($name in $environment.Keys) { - $savedEnvironment[$name] = [Environment]::GetEnvironmentVariable($name, 'Process') - [Environment]::SetEnvironmentVariable($name, $environment[$name], 'Process') - } - } - # Windows PowerShell wraps native stderr as errors, including successful Git messages. - $ErrorActionPreference = 'Continue' - $output = & git -C $RepositoryPath @Arguments 2>&1 - $exitCode = $LASTEXITCODE - $ErrorActionPreference = 'Stop' - if ($exitCode -ne 0) { - if ($AccessToken) { throw "Authenticated Git operation failed. Check Azure CLI identity and repository access." } - throw "git -C `"$RepositoryPath`" $($Arguments -join ' ') failed:`n$($output -join "`n")" - } - return $output - } - catch { - if ($AccessToken) { throw "Authenticated Git operation failed. Check Azure CLI identity and repository access." } - throw - } - finally { - foreach ($name in $savedEnvironment.Keys) { - [Environment]::SetEnvironmentVariable($name, $savedEnvironment[$name], 'Process') - } - } -} - -function Resolve-GitRepository { - param ( - [Parameter(Mandatory = $true)] - [string]$Path, - - [Parameter(Mandatory = $true)] - [string]$Name - ) - - if (-not (Test-Path -LiteralPath $Path -PathType Container)) { - throw "$Name repository path does not exist or is not a directory: $Path" - } - - $repositoryRoot = Invoke-Git -RepositoryPath $Path -Arguments @("rev-parse", "--show-toplevel") | - Select-Object -First 1 - - return [IO.Path]::GetFullPath($repositoryRoot) -} - -function Test-GitRef { - param ( - [Parameter(Mandatory = $true)] - [string]$RepositoryPath, - - [Parameter(Mandatory = $true)] - [string]$Ref - ) - - & git -C $RepositoryPath rev-parse --verify --quiet "$Ref^{commit}" 2>$null | Out-Null - return $LASTEXITCODE -eq 0 -} - -function Test-GitAncestor { - param ( - [Parameter(Mandatory = $true)] - [string]$RepositoryPath, - - [Parameter(Mandatory = $true)] - [string]$Ancestor, - - [Parameter(Mandatory = $true)] - [string]$Descendant - ) - - & git -C $RepositoryPath merge-base --is-ancestor $Ancestor $Descendant 2>$null - return $LASTEXITCODE -eq 0 -} - -function Resolve-VisualStudioBranch { - param ( - [Parameter(Mandatory = $true)] - [string]$RepositoryPath, - - [Parameter(Mandatory = $true)] - [string]$Branch, - - [string]$AccessToken - ) - - $Branch = $Branch -replace '^refs/heads/', '' - $localBranchRef = "refs/heads/$Branch" - if (-not (Test-GitRef -RepositoryPath $RepositoryPath -Ref $localBranchRef)) { - if (-not (Test-GitRef -RepositoryPath $RepositoryPath -Ref $Branch)) { - throw "Visual Studio branch or ref '$Branch' was not found." - } - - Write-Warning "Using Visual Studio ref '$Branch' without fetching because it is not a local branch with a configured upstream." - $fullRef = Invoke-Git -RepositoryPath $RepositoryPath -Arguments @("rev-parse", "--symbolic-full-name", $Branch) - if ($fullRef -notmatch '^refs/remotes/([^/]+)/(.+)$') { - throw "Supply a Visual Studio branch name, not an arbitrary ref or commit." - } - return [PSCustomObject]@{ Ref = $fullRef; Remote = $Matches[1]; RemoteBranch = "refs/heads/$($Matches[2])"; Branch = $Matches[2] } - } - - $upstream = Invoke-Git -RepositoryPath $RepositoryPath -Arguments @( - "for-each-ref", - "--format=%(upstream:short)", - $localBranchRef - ) | Select-Object -First 1 - - if (-not $upstream) { - Write-Warning "Using local Visual Studio branch '$Branch' without fetching because it has no configured upstream." - return [PSCustomObject]@{ Ref = $localBranchRef; Branch = $Branch; Remote = $null; RemoteBranch = $null } - } - - $remote = Invoke-Git -RepositoryPath $RepositoryPath -Arguments @( - "for-each-ref", - "--format=%(upstream:remotename)", - $localBranchRef - ) | Select-Object -First 1 - - $remoteBranch = Invoke-Git -RepositoryPath $RepositoryPath -Arguments @( - "for-each-ref", "--format=%(upstream:remoteref)", $localBranchRef - ) | Select-Object -First 1 - Write-Information "[VS backport] Fetching VS upstream '$upstream'." -InformationAction Continue - $null = Invoke-Git -RepositoryPath $RepositoryPath -AccessToken $AccessToken -Arguments @( - "fetch", - $remote, - "--quiet", - "--no-tags", - "${remoteBranch}:refs/remotes/$upstream" - ) - - return [PSCustomObject]@{ Ref = $upstream; Branch = $remoteBranch -replace '^refs/heads/', ''; Remote = $remote; RemoteBranch = $remoteBranch } -} - -function Get-VisualStudioInsertion { - param ( - [string]$RepositoryPath, - [string]$Snapshot, - [string]$PayloadUrl, - $Branch, - [string]$AccessToken, - [int]$DeepenBy = 100, - [int]$MaxDeepenAttempts = 10 - ) - - for ($attempt = 0; $attempt -le $MaxDeepenAttempts; $attempt++) { - $candidate = Invoke-Git -RepositoryPath $RepositoryPath -Arguments @( - "log", $Snapshot, "--first-parent", "-n", "1", "--format=%H", - "-S$PayloadUrl", "--", ".corext/Configs/components.json" - ) | Select-Object -First 1 - $shallowPath = Invoke-Git -RepositoryPath $RepositoryPath -Arguments @("rev-parse", "--git-path", "shallow") - if (-not [IO.Path]::IsPathRooted($shallowPath)) { $shallowPath = Join-Path $RepositoryPath $shallowPath } - $boundaries = if (Test-Path -LiteralPath $shallowPath) { @(Get-Content -LiteralPath $shallowPath) } else { @() } - # Pickaxe treats a shallow boundary as a root, falsely attributing every payload to it. - if ($candidate -and $candidate -notin $boundaries) { return $candidate } - if (-not $boundaries) { throw "No F# insertion was found in the Visual Studio snapshot." } - if (-not $Branch.Remote -or $attempt -eq $MaxDeepenAttempts) { - throw "Insufficient Visual Studio history to prove the insertion. Deepen the local clone and retry." - } - Write-Information "[VS backport] Deepening VS history by $DeepenBy commits (attempt $($attempt + 1)/$MaxDeepenAttempts)." -InformationAction Continue - $null = Invoke-Git -RepositoryPath $RepositoryPath -AccessToken $AccessToken -Arguments @( - "fetch", "--quiet", "--no-tags", "--deepen=$DeepenBy", $Branch.Remote, $Branch.RemoteBranch - ) - } -} - -function Resolve-FSharpBranch { - param ( - [Parameter(Mandatory = $true)] - [string]$RepositoryPath, - - [Parameter(Mandatory = $true)] - [string]$Branch, - - [Parameter(Mandatory = $true)] - [string]$Commit - ) - - $candidates = @( - "upstream/$Branch", - "origin/$Branch", - $Branch - ) - - foreach ($candidate in $candidates) { - if ((Test-GitRef -RepositoryPath $RepositoryPath -Ref $candidate) -and - (Test-GitAncestor -RepositoryPath $RepositoryPath -Ancestor $Commit -Descendant $candidate)) { - return $candidate - } - } - - $null = Invoke-Git -RepositoryPath $RepositoryPath -Arguments @("check-ref-format", "refs/heads/$Branch") - $remotes = @(Invoke-Git -RepositoryPath $RepositoryPath -Arguments @("remote")) - foreach ($remote in @("upstream", "origin")) { - if ($remote -notin $remotes) { continue } - $candidate = "refs/remotes/$remote/$Branch" - Write-Information "[VS backport] Source commit is not on a cached F# ref; fetching '$remote/$Branch'." -InformationAction Continue - $null = Invoke-Git -RepositoryPath $RepositoryPath -Arguments @( - "fetch", "--quiet", "--no-tags", $remote, "+refs/heads/${Branch}:$candidate" - ) - if ((Test-GitRef -RepositoryPath $RepositoryPath -Ref $candidate) -and - (Test-GitAncestor -RepositoryPath $RepositoryPath -Ancestor $Commit -Descendant $candidate)) { - return $candidate - } - } - - throw "F# source commit '$Commit' was not found on branch '$Branch' after checking local refs and fetching configured upstream/origin remotes. Check the remote URLs and deepen the F# clone if it is shallow." -} - -function ConvertTo-PowerShellLiteral { - param ( - [Parameter(Mandatory = $true)] - [string]$Value - ) - - return "'$($Value.Replace("'", "''"))'" -} - -function Set-PipelineVariable { - param ( - [Parameter(Mandatory = $true)] - [string]$Content, - - [Parameter(Mandatory = $true)] - [string]$Name, - - [Parameter(Mandatory = $true)] - [string]$Value - ) - - $escapedName = [regex]::Escape($Name) - $pattern = "(?m)(^\s*-\s+name:\s*$escapedName\s*\r?\n\s*value:\s*)[^\r\n]+" - $matches = [regex]::Matches($Content, $pattern) - - if ($matches.Count -ne 1) { - throw "Expected one '$Name' variable in azure-pipelines.yml; found $($matches.Count)." - } - - return [regex]::Replace( - $Content, - $pattern, - { param($match) $match.Groups[1].Value + $Value } - ) -} - -function Set-FixedVSMinorVersion { - param ( - [string]$Content, - [ValidateScript({ $_ -cmatch '^[0-9]{1,5}$' -and [int]$_ -le 65534 })] - [string]$Minor - ) - - $document = [xml]$Content - $minorNodes = $document.SelectNodes('/Project/PropertyGroup/VSMinorVersion') - $minorPattern = '\s*[0-9]+\s*' - if ($minorNodes.Count -ne 1 -or $minorNodes[0].ParentNode.HasAttribute('Condition') -or - [regex]::Matches($Content, $minorPattern).Count -ne 1) { - throw "Expected one unconditional literal VSMinorVersion in eng\Versions.props." - } - $Content = [regex]::Replace($Content, $minorPattern, "$([int]$Minor)") - $flagPattern = '[^<]*' - $flagNodes = $document.SelectNodes('/Project/PropertyGroup/UseVSScheduledMinorVersion') - $flag = 'false' - if ($flagNodes.Count -eq 0) { - $newline = if ($Content.Contains("`r`n")) { "`r`n" } else { "`n" } - $Content = [regex]::Replace($Content, "(?m)^([ `t]*)($minorPattern)", { - param($match) - $match.Value + $newline + $match.Groups[1].Value + $flag - }) - } - elseif ($flagNodes.Count -eq 1 -and -not $flagNodes[0].ParentNode.HasAttribute('Condition') -and - [regex]::Matches($Content, $flagPattern).Count -eq 1) { - $Content = [regex]::Replace($Content, $flagPattern, $flag) - } - else { throw "Unsupported scheduled minor layout in eng\Versions.props." } - - $flagIndex = $Content.IndexOf($flag, [StringComparison]::Ordinal) - $minorIndex = $Content.IndexOf('', [StringComparison]::Ordinal) - $scheduleImport = [regex]::Match($Content, ']*\bProject="[^"]*VSMinorVersion\.props"[^>]*/>') - if ($flagIndex -lt 0 -or ($scheduleImport.Success -and - ($scheduleImport.Index -lt $flagIndex -or $scheduleImport.Index -lt $minorIndex))) { - throw "The fixed minor and schedule opt-out must precede the schedule import in eng\Versions.props." - } - return $Content -} - -function Get-AzureDevOpsToken { - $ErrorActionPreference = 'Continue' - $token = & az account get-access-token ` - --resource "499b84ac-1321-427f-aa17-267ca6975798" ` - --query accessToken ` - --only-show-errors ` - -o tsv 2>&1 - $exitCode = $LASTEXITCODE - $ErrorActionPreference = 'Stop' - - if ($exitCode -ne 0 -or -not $token) { - throw "Unable to acquire an Azure DevOps access token. Run 'az login' and try again." - } - - return $token | Select-Object -First 1 -} - -function Get-FSharpBuild { - param ( - [Parameter(Mandatory = $true)] - [string]$BuildNumber, - - [Parameter(Mandatory = $true)] - [string]$SourceBranch - ) - - $definitionId = 499 - $escapedBuildNumber = [Uri]::EscapeDataString($BuildNumber) - $escapedSourceBranch = [Uri]::EscapeDataString("refs/heads/$SourceBranch") - $url = "https://dev.azure.com/dnceng/internal/_apis/build/builds" + - "?definitions=$definitionId" + - "&buildNumber=$escapedBuildNumber" + - "&branchName=$escapedSourceBranch" + - "&api-version=7.1" - $webClient = [System.Net.WebClient]::new() - - try { - $webClient.Headers.Add("Authorization", "Bearer $(Get-AzureDevOpsToken)") - $response = $webClient.DownloadString($url) | ConvertFrom-Json - } - catch { - throw "Failed to query fsharp-ci build '$BuildNumber' for branch '$SourceBranch' from Azure DevOps: $($_.Exception.Message)" - } - finally { - $webClient.Dispose() - } - - $builds = @($response.value) - if ($builds.Count -ne 1) { - throw "Expected one fsharp-ci build numbered '$BuildNumber' for branch '$SourceBranch'; found $($builds.Count)." - } - - return $builds[0] -} - -function Invoke-VSBackportBranchPoint { - param ( - [Parameter(Mandatory = $true)][string]$VSBranch, - [string]$VSRepoPath, - [Parameter(Mandatory = $true)][string]$FSharpRepoPath, - [Parameter(Mandatory = $true)][string]$NewBranchName, - [Parameter(Mandatory = $true)] - [ValidateScript({ $_ -cmatch '^[0-9]{1,5}$' -and [int]$_ -le 65534 })] - [string]$VSMinorVersion, - [switch]$Execute - ) - - $mode = if ($Execute) { 'Execute' } else { 'Preview' } - Write-Information "[VS backport] $mode for VS '$VSBranch': F# branch '$NewBranchName', fixed minor $VSMinorVersion." -InformationAction Continue - $fsharpRepository = Resolve-GitRepository -Path $FSharpRepoPath -Name "F#" - Write-Information "[VS backport] Using F# repository '$fsharpRepository'." -InformationAction Continue - - $null = Invoke-Git -RepositoryPath $fsharpRepository -Arguments @( - "check-ref-format", - "--branch", - $NewBranchName - ) - - if (Test-GitRef -RepositoryPath $fsharpRepository -Ref "refs/heads/$NewBranchName") { - throw "Local F# branch '$NewBranchName' already exists." - } - - if ($NewBranchName -notmatch "^(feature|release)/") { - Write-Warning "Branch '$NewBranchName' is not covered by the pipeline's feature/* or release/* CI triggers." - } - - if ($Execute) { - $trackedChanges = Invoke-Git -RepositoryPath $fsharpRepository -Arguments @( - "status", - "--porcelain", - "--untracked-files=no" - ) - - if ($trackedChanges) { - throw "The F# repository has tracked working-tree changes. Commit or stash them before using -Execute." - } - } - - $ownedRepository = $null - try { - Write-Information '[VS backport] Acquiring Azure DevOps credentials from Azure CLI.' -InformationAction Continue - $accessToken = Get-AzureDevOpsToken - if ($VSRepoPath) { - $vsRepository = Resolve-GitRepository -Path $VSRepoPath -Name "Visual Studio" - Write-Information "[VS backport] Using local VS repository '$vsRepository' without changing its checkout." -InformationAction Continue - } - else { - $VSBranch = $VSBranch -replace '^refs/heads/', '' - $null = Invoke-Git -RepositoryPath $fsharpRepository -Arguments @("check-ref-format", "refs/heads/$VSBranch") - # Keep scratch storage invocation-owned and under the caller's working directory. - $clonePath = Join-Path (Get-Location).Path (".vs-backport-" + [Guid]::NewGuid().ToString('N')) - $null = New-Item -ItemType Directory -Path $clonePath - $ownedRepository = $clonePath - Write-Information "[VS backport] Cloning VS branch '$VSBranch' into '$ownedRepository' (depth 100, no checkout)." -InformationAction Continue - $null = Invoke-Git -RepositoryPath $ownedRepository -AccessToken $accessToken -Arguments @( - "clone", "--quiet", "--depth=100", "--single-branch", "--no-tags", "--no-checkout", - "--branch", $VSBranch, "https://devdiv.visualstudio.com/DefaultCollection/DevDiv/_git/VS", "." - ) - $vsRepository = $ownedRepository - } - - $vsBranchInfo = Resolve-VisualStudioBranch -RepositoryPath $vsRepository -Branch $VSBranch -AccessToken $accessToken - $vsBranchRef = $vsBranchInfo.Ref - $VSBranch = $vsBranchInfo.Branch - $vsSnapshot = Invoke-Git -RepositoryPath $vsRepository -Arguments @("rev-parse", "$vsBranchRef^{commit}") | - Select-Object -First 1 - Write-Information "[VS backport] Reading F# payload from VS snapshot $vsSnapshot; locating its insertion." -InformationAction Continue - - $componentsPath = ".corext/Configs/components.json" - $branchComponentsJson = (Invoke-Git -RepositoryPath $vsRepository -Arguments @( - "show", - "${vsSnapshot}:$componentsPath" - )) -join "`n" - $branchComponents = $branchComponentsJson | ConvertFrom-Json - $fsharpComponent = $branchComponents.Components."Microsoft.FSharp" - - if (-not $fsharpComponent) { - throw "The F# component was not found in $componentsPath on Visual Studio branch '$VSBranch'." - } - - $insertionCommit = Get-VisualStudioInsertion -RepositoryPath $vsRepository -Snapshot $vsSnapshot ` - -PayloadUrl $fsharpComponent.url -Branch $vsBranchInfo -AccessToken $accessToken - Write-Information "[VS backport] Found VS insertion $insertionCommit." -InformationAction Continue - $insertionDate = (Invoke-Git -RepositoryPath $vsRepository -Arguments @( - "show", - "-s", - "--format=%cI", - $insertionCommit - )) | Select-Object -First 1 - $insertionSubject = (Invoke-Git -RepositoryPath $vsRepository -Arguments @( - "show", - "-s", - "--format=%s", - $insertionCommit - )) | Select-Object -First 1 - - $buildNumberMatch = [regex]::Match( - $fsharpComponent.url, - "/dotnet-fsharp/(?.+)/(?[^/;]+);", - [System.Text.RegularExpressions.RegexOptions]::IgnoreCase - ) - if (-not $buildNumberMatch.Success) { - throw "Unable to extract the F# source branch and build number from component URL '$($fsharpComponent.url)'." - } - - $sourceBranch = [Uri]::UnescapeDataString($buildNumberMatch.Groups["sourceBranch"].Value) - $buildNumber = $buildNumberMatch.Groups["buildNumber"].Value - Write-Information "[VS backport] Looking up fsharp-ci build '$buildNumber' for source branch '$sourceBranch'." -InformationAction Continue - $build = Get-FSharpBuild -BuildNumber $buildNumber -SourceBranch $sourceBranch - $branchPoint = $build.sourceVersion - - if ($branchPoint -notmatch "^[0-9a-f]{40}$") { - throw "Build '$buildNumber' returned invalid source version '$branchPoint'." - } - - $fsharpBranchRef = Resolve-FSharpBranch ` - -RepositoryPath $fsharpRepository ` - -Branch $sourceBranch ` - -Commit $branchPoint - Write-Information "[VS backport] Resolved F# source commit $branchPoint on '$fsharpBranchRef'." -InformationAction Continue - - $branchPointDate = (Invoke-Git -RepositoryPath $fsharpRepository -Arguments @( - "show", - "-s", - "--format=%cI", - $branchPoint - )) | Select-Object -First 1 - $branchPointSubject = (Invoke-Git -RepositoryPath $fsharpRepository -Arguments @( - "show", - "-s", - "--format=%s", - $branchPoint - )) | Select-Object -First 1 - $componentVersionProperty = $fsharpComponent.PSObject.Properties["version"] - $fsharpRepoLiteral = ConvertTo-PowerShellLiteral -Value $fsharpRepository - $newBranchLiteral = ConvertTo-PowerShellLiteral -Value $NewBranchName - $branchPointLiteral = ConvertTo-PowerShellLiteral -Value $branchPoint - $gitCommand = "git -C $fsharpRepoLiteral switch -c $newBranchLiteral $branchPointLiteral" - $action = "Preview" - - # Preflight both files at the exact source SHA, even in preview. - Write-Information '[VS backport] Validating pipeline mappings and fixed-minor edits at the branch point.' -InformationAction Continue - $branchPointPipelineContent = (Invoke-Git -RepositoryPath $fsharpRepository -Arguments @( - "show", "${branchPoint}:azure-pipelines.yml" - )) -join "`n" - $branchPointPipelineContent = Set-PipelineVariable ` - -Content $branchPointPipelineContent -Name "FSharpReleaseBranchName" -Value $NewBranchName - $null = Set-PipelineVariable ` - -Content $branchPointPipelineContent -Name "VSInsertionTargetBranchName" -Value $VSBranch - $branchPointVersionsContent = (Invoke-Git -RepositoryPath $fsharpRepository -Arguments @( - "show", "${branchPoint}:eng/Versions.props" - )) -join "`n" - $null = Set-FixedVSMinorVersion -Content $branchPointVersionsContent -Minor $VSMinorVersion - - if ($Execute) { - $targetFiles = @(Invoke-Git -RepositoryPath $fsharpRepository -Arguments @("ls-tree", "-r", "--name-only", $branchPoint)) - $untrackedPaths = @(Invoke-Git -RepositoryPath $fsharpRepository -Arguments @("ls-files", "--others", "--directory", "--no-empty-directory")) - foreach ($path in $untrackedPaths) { - foreach ($target in $targetFiles) { - if ($target -eq $path -or $target.StartsWith($path.TrimEnd('/') + '/', [StringComparison]::OrdinalIgnoreCase) -or - $path.StartsWith($target + '/', [StringComparison]::OrdinalIgnoreCase)) { - throw "Untracked or ignored path '$path' collides with the selected F# source commit." - } - } - } - Write-Information "[VS backport] Creating and checking out '$NewBranchName' at $branchPoint." -InformationAction Continue - $null = Invoke-Git -RepositoryPath $fsharpRepository -Arguments @( - "switch", - "--no-overwrite-ignore", - "-c", - $NewBranchName, - $branchPoint - ) - - try { - foreach ($file in @("azure-pipelines.yml", "eng\Versions.props")) { - Write-Information "[VS backport] Updating '$file'." -InformationAction Continue - $path = Join-Path $fsharpRepository $file - $content = [IO.File]::ReadAllText($path) - if ($file -eq "azure-pipelines.yml") { - $content = Set-PipelineVariable -Content $content -Name "FSharpReleaseBranchName" -Value $NewBranchName - $content = Set-PipelineVariable -Content $content -Name "VSInsertionTargetBranchName" -Value $VSBranch - } - else { - $content = Set-FixedVSMinorVersion -Content $content -Minor $VSMinorVersion - } - $bytes = [IO.File]::ReadAllBytes($path) - $hasBom = $bytes.Length -ge 3 -and $bytes[0] -eq 239 -and $bytes[1] -eq 187 -and $bytes[2] -eq 191 - [IO.File]::WriteAllText($path, $content, [Text.UTF8Encoding]::new($hasBom)) - } - } - catch { - throw "Branch '$NewBranchName' was created, but edits failed and may be partial. Inspect the worktree; no rollback was attempted. $($_.Exception.Message)" - } - $action = "Executed" - } - - if ($Execute) { - Write-Information '[VS backport] Branch and edits are ready; changes are uncommitted. Nothing was pushed or inserted into VS.' -InformationAction Continue - } - else { - Write-Information '[VS backport] Preview complete; no F# files or branches were changed. Use -Execute to apply the planned edits.' -InformationAction Continue - } - [PSCustomObject]@{ - Action = $action - VSBranch = $VSBranch - VSResolvedBranchRef = $vsBranchRef - VSSnapshot = $vsSnapshot - VSInsertionCommit = $insertionCommit - VSInsertionDate = $insertionDate - VSInsertionSubject = $insertionSubject - FSharpComponentVersion = if ($componentVersionProperty) { $componentVersionProperty.Value } else { $null } - FSharpComponentUrl = $fsharpComponent.url - FSharpSourceBranch = $sourceBranch - FSharpResolvedBranchRef = $fsharpBranchRef - FSharpBuildNumber = $buildNumber - FSharpBuildId = $build.id - FSharpBranchPoint = $branchPoint - FSharpBranchPointDate = $branchPointDate - FSharpBranchPointSubject = $branchPointSubject - NewBranchName = $NewBranchName - GitCommand = $gitCommand - PipelineFSharpBranch = $NewBranchName - PipelineVSBranch = $VSBranch - VSMinorVersion = [int]$VSMinorVersion - UseVSScheduledMinorVersion = $false - PlannedEdits = @("azure-pipelines.yml: FSharpReleaseBranchName=$NewBranchName; VSInsertionTargetBranchName=$VSBranch", - "eng\Versions.props: VSMinorVersion=$([int]$VSMinorVersion); UseVSScheduledMinorVersion=false") - } - } - finally { - if ($ownedRepository) { - Write-Information "[VS backport] Removing temporary VS repository '$ownedRepository'." -InformationAction Continue - Remove-Item -LiteralPath $ownedRepository -Recurse -Force - } - } -} - -if ($MyInvocation.InvocationName -ne '.') { - Invoke-VSBackportBranchPoint @PSBoundParameters -} diff --git a/eng/release/scripts/VSMinorVersion.Tests.ps1 b/eng/release/scripts/VSMinorVersion.Tests.ps1 deleted file mode 100644 index e1d20b5889e..00000000000 --- a/eng/release/scripts/VSMinorVersion.Tests.ps1 +++ /dev/null @@ -1,178 +0,0 @@ -$versionProject = Join-Path $PSScriptRoot '..\VSVersion.proj' - -function Invoke-VersionEvaluation { - param ([hashtable]$Properties = @{}, [string]$Project = $versionProject, [switch]$EvaluationOnly) - - $arguments = @('msbuild', $Project, '-nologo', '-warnaserror', '-p:VSBuildTimestampUtc=') - $arguments += if ($EvaluationOnly) { '-getProperty:VSMinorVersion,VSMinorVersionIsHeuristic' } else { '-t:PrintVSVersion' } - foreach ($name in $Properties.Keys) { - $arguments += "-p:$name=$($Properties[$name])" - } - $output = & dotnet @arguments 2>&1 - [PSCustomObject]@{ ExitCode = $LASTEXITCODE; Output = $output -join "`n" } -} - -Describe 'Heuristic VS minor versions' { - $cases = @() - $transitions = @( - @{ At = '2026-05-02T19:00:00Z'; Minor = 8 }, - @{ At = '2026-05-30T19:00:00Z'; Minor = 9 }, - @{ At = '2026-07-04T19:00:00Z'; Minor = 10 }, - @{ At = '2026-08-01T19:00:00Z'; Minor = 11 }, - @{ At = '2026-08-29T19:00:00Z'; Minor = 12 }, - @{ At = '2026-10-03T19:00:00Z'; Minor = 13 }, - @{ At = '2026-10-31T19:00:00Z'; Minor = 14 }, - @{ At = '2026-11-28T20:00:00Z'; Minor = 15 }, - @{ At = '2027-01-02T20:00:00Z'; Minor = 16 }, - @{ At = '2027-02-27T20:00:00Z'; Minor = 18 }, - @{ At = '2027-04-03T19:00:00Z'; Minor = 19 }, - @{ At = '2028-03-04T20:00:00Z'; Minor = 30 } - ) - foreach ($transition in $transitions) { - $at = [DateTimeOffset]::Parse($transition.At) - foreach ($ticks in @(-1, 0, 1)) { - $cases += @{ - Timestamp = $at.AddTicks($ticks).ToString('o') - Minor = $transition.Minor - [int]($ticks -lt 0) - } - } - } - - It 'selects minor at ' -TestCases $cases { - param ($Timestamp, $Minor) - $result = Invoke-VersionEvaluation @{ VSBuildTimestampUtc = $Timestamp } - $result.ExitCode | Should Be 0 - $result.Output | Should Match "VSMinorVersion=$Minor; VSAssemblyVersion=18\.$Minor\.0\.0" - $result.Output | Should Match 'warning FSVS1001' - } - - It 'uses the literal local minor without a timestamp' { - $result = Invoke-VersionEvaluation - $result.ExitCode | Should Be 0 - $result.Output | Should Match 'VSMinorVersion=8;' - $result.Output | Should Not Match 'FSVS1001' - } - - It 'retains the minor during the 24-hour delay at ' -TestCases @( - @{ Timestamp = '2026-08-28T19:00:00Z'; Minor = 11 }, - @{ Timestamp = '2026-07-03T19:00:00Z'; Minor = 9 }, - @{ Timestamp = '2026-10-30T19:00:00Z'; Minor = 13 }, - @{ Timestamp = '2026-11-27T20:00:00Z'; Minor = 14 } - ) { - param ($Timestamp, $Minor) - $result = Invoke-VersionEvaluation @{ VSBuildTimestampUtc = $Timestamp } - $result.ExitCode | Should Be 0 - $result.Output | Should Match "VSMinorVersion=$Minor;" - } - - It 'normalizes equivalent offsets including winter offsets' -TestCases @( - @{ Timestamp = '2026-08-29T12:00:00-07:00'; Minor = 12 }, - @{ Timestamp = '2026-08-29T21:00:00+02:00'; Minor = 12 }, - @{ Timestamp = '2026-11-28T12:00:00-08:00'; Minor = 15 }, - @{ Timestamp = '2026-10-31T12:00:00-07:00'; Minor = 14 } - ) { - param ($Timestamp, $Minor) - $result = Invoke-VersionEvaluation @{ VSBuildTimestampUtc = $Timestamp } - $result.ExitCode | Should Be 0 - $result.Output | Should Match "VSMinorVersion=$Minor;" - } - - It 'keeps a fixed backport minor with a later timestamp' { - $result = Invoke-VersionEvaluation @{ - UseVSScheduledMinorVersion = 'false' - VSMinorVersion = '11' - VSBuildTimestampUtc = '2026-12-02T19:00:00Z' - } - $result.ExitCode | Should Be 0 - $result.Output | Should Match 'VSMinorVersion=11;' - $result.Output | Should Not Match 'FSVS1001' - } - - It 'rejects invalid input ' -TestCases @( - @{ Timestamp = 'garbage' }, - @{ Timestamp = '2026-09-11T12:00:00' }, - @{ Timestamp = '2024-01-01T00:00:00Z' }, - @{ Timestamp = '9999-12-31T19:00:00Z' } - ) { - param ($Timestamp) - (Invoke-VersionEvaluation @{ VSBuildTimestampUtc = $Timestamp }).ExitCode | Should Not Be 0 - } - - It 'requires a deliberate calibration update for another VS major' { - $result = Invoke-VersionEvaluation @{ VSMajorVersion = 19; VSBuildTimestampUtc = '2026-09-11T12:00:00Z' } - $result.ExitCode | Should Not Be 0 - $result.Output | Should Match 'covers major 18 only' - } - - It 'rejects computed minors outside the assembly component range' { - $result = Invoke-VersionEvaluation @{ VSBuildTimestampUtc = '8000-01-01T00:00:00Z' } - $result.ExitCode | Should Not Be 0 - $result.Output | Should Match 'exceeds 65534' - } - - It 'rejects invalid input during evaluation alone with Arcade disabled=' -TestCases @( - @{ Disabled = 'true' }, - @{ Disabled = 'false' } - ) { - param ($Disabled) - $project = Join-Path $PSScriptRoot '..\..\..\vsintegration\src\FSharp.ProjectSystem.FSharp\FSharp.ProjectSystem.FSharp.fsproj' - foreach ($invalid in @( - @{ VSBuildTimestampUtc = 'garbage' }, - @{ VSBuildTimestampUtc = '2026-08-29T20:30:00' }, - @{ VSBuildTimestampUtc = '2024-01-01T00:00:00Z' }, - @{ VSBuildTimestampUtc = '8000-01-01T00:00:00Z' }, - @{ VSBuildTimestampUtc = '2026-09-11T12:00:00Z'; VSMajorVersion = '19' } - )) { - $invalid.DISABLE_ARCADE = $Disabled - $result = Invoke-VersionEvaluation $invalid -Project $project -EvaluationOnly - $result.ExitCode | Should Not Be 0 - } - } - - It 'exposes heuristic status and normalizes offsets during evaluation alone' { - foreach ($timestamp in @('2026-08-29T19:00:00Z', '2026-08-29T21:00:00+02:00', '2026-08-29T12:00:00-07:00')) { - $result = Invoke-VersionEvaluation @{ VSBuildTimestampUtc = $timestamp } -EvaluationOnly - $result.ExitCode | Should Be 0 - $properties = ($result.Output | ConvertFrom-Json).Properties - $properties.VSMinorVersion | Should Be '12' - $properties.VSMinorVersionIsHeuristic | Should Be 'true' - } - foreach ($fixed in @( - @{}, - @{ UseVSScheduledMinorVersion = 'false'; VSMinorVersion = '11'; VSBuildTimestampUtc = 'not-a-timestamp' } - )) { - $result = Invoke-VersionEvaluation $fixed -EvaluationOnly - $result.ExitCode | Should Be 0 - ($result.Output | ConvertFrom-Json).Properties.VSMinorVersionIsHeuristic | Should Be 'false' - } - } - - It 'propagates the selected minor to VS redirects with Arcade disabled=' -TestCases @( - @{ Disabled = 'true' }, - @{ Disabled = 'false' } - ) { - param ($Disabled) - $project = Join-Path $PSScriptRoot '..\..\..\vsintegration\src\FSharp.ProjectSystem.FSharp\FSharp.ProjectSystem.FSharp.fsproj' - $output = & dotnet msbuild $project -nologo "-p:DISABLE_ARCADE=$Disabled" ` - -p:VSBuildTimestampUtc=2026-09-11T12:00:00Z ` - -getProperty:VSAssemblyVersion,FSCoreVersion,FSharpCompilerServiceVersion -getItem:AssemblyAttribute - $LASTEXITCODE | Should Be 0 - $data = ($output -join "`n") | ConvertFrom-Json - $data.Properties.VSAssemblyVersion | Should Be '18.12.0.0' - $redirects = @($data.Items.AssemblyAttribute | Where-Object { - $_.Identity -eq 'Microsoft.VisualStudio.Shell.ProvideBindingRedirectionAttribute' -and - $_.AssemblyName -ne 'FSharp.Core' - }) - $redirects.Count | Should Be 2 - foreach ($redirect in $redirects) { - $redirect.OldVersionUpperBound | Should Be '18.12.0.0' - $redirect.NewVersion | Should Be '18.12.0.0' - } - $baseline = & dotnet msbuild $project -nologo "-p:DISABLE_ARCADE=$Disabled" ` - -p:VSBuildTimestampUtc= -getProperty:FSCoreVersion,FSharpCompilerServiceVersion - $LASTEXITCODE | Should Be 0 - $unchanged = ($baseline -join "`n") | ConvertFrom-Json - $data.Properties.FSCoreVersion | Should Be $unchanged.Properties.FSCoreVersion - $data.Properties.FSharpCompilerServiceVersion | Should Be $unchanged.Properties.FSharpCompilerServiceVersion - } -} diff --git a/eng/scripts/GetVSMinorVersion.fsx b/eng/scripts/GetVSMinorVersion.fsx new file mode 100644 index 00000000000..b2ce0705b92 --- /dev/null +++ b/eng/scripts/GetVSMinorVersion.fsx @@ -0,0 +1,27 @@ +open System +open System.Globalization + +let dateBefore predicate (basis: DateOnly) = + Seq.initInfinite (fun days -> basis.AddDays(-(days + 1))) + |> Seq.find predicate + +let isFirstTuesday (date: DateOnly) = + date.DayOfWeek = DayOfWeek.Tuesday && date.Day <= 7 + +let isFriday (date: DateOnly) = + date.DayOfWeek = DayOfWeek.Friday + +let monthsSince (basis: DateOnly) (date: DateOnly) = + Seq.initInfinite basis.AddMonths + |> Seq.findIndex (fun month -> + month.Year = date.Year && month.Month = date.Month) + +let currentDate = + DateOnly.Parse(fsi.CommandLineArgs[1], CultureInfo.InvariantCulture) + +currentDate.AddMonths(1) +|> dateBefore (fun tuesday -> + isFirstTuesday tuesday + && (tuesday |> dateBefore isFriday) <= currentDate) +|> monthsSince (DateOnly(2025, 9, 1)) +|> printfn "%d" diff --git a/eng/tests/VSMinorVersion.Tests.fsx b/eng/tests/VSMinorVersion.Tests.fsx new file mode 100644 index 00000000000..5a1ac46e2bc --- /dev/null +++ b/eng/tests/VSMinorVersion.Tests.fsx @@ -0,0 +1,64 @@ +open System +open System.Diagnostics +open System.Globalization +open System.IO +open System.Text.Json + +let root = Path.GetFullPath(Path.Combine(__SOURCE_DIRECTORY__, "../..")) + +let run minor arguments = + let start = ProcessStartInfo(Environment.ProcessPath, RedirectStandardOutput = true, RedirectStandardError = true) + start.Environment["VSMinorVersion"] <- minor + for argument in arguments do + start.ArgumentList.Add argument + use child = Process.Start start + let output, errors = child.StandardOutput.ReadToEndAsync(), child.StandardError.ReadToEndAsync() + child.WaitForExit() + child.ExitCode, output.Result.Trim(), errors.Result + +for minor, text in + [ 0, "2025-08-29"; 1, "2025-10-03"; 2, "2025-10-31"; 3, "2025-11-28" + 4, "2026-01-02"; 5, "2026-01-30"; 6, "2026-02-27"; 7, "2026-04-03" + 8, "2026-05-01"; 9, "2026-05-29"; 10, "2026-07-03"; 11, "2026-07-31" + 12, "2026-08-28"; 13, "2026-10-02"; 14, "2026-10-30"; 15, "2026-11-27" ] do + let friday = DateOnly.Parse(text, CultureInfo.InvariantCulture) + for days in (if minor = 0 then [ 0; 4 ] else [ -1; 0; 4 ]) do + let date = friday.AddDays(days).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) + let expected = minor - (if days < 0 then 1 else 0) + let code, output, errors = run "" [ "fsi"; Path.Combine(root, "eng/scripts/GetVSMinorVersion.fsx"); date ] + if code <> 0 || output <> string expected then + failwith $"{date}: expected {expected}, got {output}. {errors}" + +let fixture = Path.Combine(Path.GetTempPath(), $"fsharp-vs-version-{Guid.NewGuid():N}") +Directory.CreateDirectory fixture |> ignore +try + let project = Path.Combine(fixture, "Version.fsproj") + let source = Path.Combine(fixture, "Assembly.fsx") + File.WriteAllText(project, $""" + + {root}/trueF# + + + + """) + for usesVS, minor, shouldFail in [ false, "", false; true, "", true; true, "UNSET", true; true, "13", false ] do + let code, output, errors = + run minor [ "msbuild"; project; "-nologo"; $"-p:UseVsMicroBuildAssemblyVersion={usesVS}" + "-getProperty:AssemblyVersion,NoWarn,WarningsAsErrors" ] + if code <> 0 then failwith errors + use data = JsonDocument.Parse output + let property (name: string) = data.RootElement.GetProperty("Properties").GetProperty(name).GetString() + let assemblyVersion = property "AssemblyVersion" + let noWarn = (property "NoWarn").Replace(';', ',') + let warningsAsErrors = (property "WarningsAsErrors").Replace(';', ',') + File.WriteAllText(source, $"open System.Reflection\n[]\ndo ()") + let code, _, errors = + run "" [ "fsi" + if noWarn <> "" then $"--nowarn:{noWarn}" + $"--warnaserror:{warningsAsErrors}"; source ] + if (code <> 0) <> shouldFail || (shouldFail && not (errors.Contains("FS2003", StringComparison.Ordinal))) then + failwith $"VS consumer={usesVS}, minor='{minor}': unexpected exit {code}. {errors}" +finally + Directory.Delete(fixture, true) + +printfn "VS minor version tests passed." From 6e871816aba5f1af9a14a5f4df1ce319c8af5a4c Mon Sep 17 00:00:00 2001 From: Tomas Grosup Date: Fri, 9 Oct 2026 09:51:10 +0200 Subject: [PATCH 5/6] Remove redundant VS versioning scaffolding Drop the added test and CI step, leave shell builds unchanged, and let MSBuild own release pins and VS-only version validation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- DEVGUIDE.md | 7 +--- azure-pipelines-PR.yml | 3 -- eng/Build.ps1 | 21 +++------- eng/build.sh | 19 --------- eng/tests/VSMinorVersion.Tests.fsx | 64 ------------------------------ 5 files changed, 7 insertions(+), 107 deletions(-) delete mode 100644 eng/tests/VSMinorVersion.Tests.fsx diff --git a/DEVGUIDE.md b/DEVGUIDE.md index 6c1a114abcf..d521253ea91 100644 --- a/DEVGUIDE.md +++ b/DEVGUIDE.md @@ -365,18 +365,15 @@ As you would expect, doing this requires both Windows and Visual Studio are inst See [Developing on Windows](#Developing-on-Windows) for instructions to install what is needed; it's the same prerequisites. -`Build.cmd` and `build.sh` set `VSMinorVersion` from the UTC date. CI uses the pipeline start date through `VSBuildDateUtc`. +`Build.cmd` sets `VSMinorVersion` from the UTC date. CI uses the pipeline start date through `VSBuildDateUtc`. The estimate changes on the Friday before the first Tuesday of each month. September 2025 corresponds to minor 0. On release branches, replace the conditional `UNSET` in `eng/Versions.props` with an unconditional numeric pin. -For direct VS-project builds or IDE builds, set the version before starting MSBuild or Visual Studio: - +For direct VS-project or IDE builds, set the version before starting MSBuild or Visual Studio: ```powershell $env:VSMinorVersion = & .\.dotnet\dotnet.exe fsi .\eng\scripts\GetVSMinorVersion.fsx ([DateTime]::UtcNow.ToString('yyyy-MM-dd', [Globalization.CultureInfo]::InvariantCulture)) ``` -VS builds without a valid version fail. Unrelated compiler, Core, and test projects do not need this property. - ### Quickly see your changes locally First, ensure that `VisualFSharpDebug` is the startup project. diff --git a/azure-pipelines-PR.yml b/azure-pipelines-PR.yml index a9a3ea9c618..e1d632d1a16 100644 --- a/azure-pipelines-PR.yml +++ b/azure-pipelines-PR.yml @@ -270,9 +270,6 @@ stages: NativeToolsOnMachine: true displayName: Build - - script: .dotnet\dotnet.exe fsi eng\tests\VSMinorVersion.Tests.fsx - displayName: Check VS minor versions - - task: PublishTestResults@2 displayName: Publish Test Results inputs: diff --git a/eng/Build.ps1 b/eng/Build.ps1 index b6d76921c99..034c295a391 100644 --- a/eng/Build.ps1 +++ b/eng/Build.ps1 @@ -575,33 +575,22 @@ try { } $buildTool = InitializeBuildTool - $dotnetExe = Join-Path (InitializeDotNetCli -install:$restore) (GetExecutableFileName 'dotnet') - $vsMinorVersion = ([xml](Get-Content "$RepoRoot/eng/Versions.props" -Raw)).SelectSingleNode('/Project/PropertyGroup/VSMinorVersion').InnerText + $dotnetPath = InitializeDotNetCli + $env:DOTNET_ROOT = "$dotnetPath" + $env:VSMinorVersion = $null $branch = if ($env:SYSTEM_PULLREQUEST_TARGETBRANCH) { $env:SYSTEM_PULLREQUEST_TARGETBRANCH } elseif ($env:BUILD_SOURCEBRANCH) { $env:BUILD_SOURCEBRANCH } else { & git -C $RepoRoot branch --show-current } - if ($branch -match '^(refs/heads/)?release/') { - if ($vsMinorVersion -notmatch '^\d+$') { - throw "Release branches must pin VSMinorVersion in eng/Versions.props." - } - $override = $properties -match '^/p:VSMinorVersion=' - if ($override -and ($override -ne "/p:VSMinorVersion=$vsMinorVersion")) { - throw "VSMinorVersion must match the release branch's pin ($vsMinorVersion)." - } - } - if ($vsMinorVersion -eq 'UNSET') { + if ($branch -notmatch '^(refs/heads/)?release/') { $date = if ($env:VSBUILDDATEUTC) { $env:VSBUILDDATEUTC } else { [DateTime]::UtcNow.ToString('yyyy-MM-dd', [Globalization.CultureInfo]::InvariantCulture) } - $vsMinorVersion = & $dotnetExe fsi "$PSScriptRoot/scripts/GetVSMinorVersion.fsx" $date + $env:VSMinorVersion = & (Join-Path $dotnetPath (GetExecutableFileName 'dotnet')) fsi "$PSScriptRoot/scripts/GetVSMinorVersion.fsx" $date if ($LASTEXITCODE -ne 0) { throw "VS minor version calculation failed." } } - $env:VSMinorVersion = "$vsMinorVersion" $toolsetBuildProj = InitializeToolset TryDownloadDotnetFrameworkSdk $nativeTools = InitializeNativeTools - $dotnetPath = InitializeDotNetCli - $env:DOTNET_ROOT = "$dotnetPath" Get-Item -Path Env: if ($bootstrap) { diff --git a/eng/build.sh b/eng/build.sh index 608a83760ef..2af459b621e 100755 --- a/eng/build.sh +++ b/eng/build.sh @@ -387,25 +387,6 @@ InitializeDotNetCli $restore # Apphosts (bootstrap fsc, testhost, etc.) resolve runtimes via DOTNET_ROOT, not PATH. export DOTNET_ROOT="$DOTNET_INSTALL_DIR" -vs_minor=$(VSMinorVersion= "$DOTNET_INSTALL_DIR/dotnet" msbuild "$scriptroot/Versions.props" -nologo -getProperty:VSMinorVersion) || ExitWithExitCode $? -branch=${SYSTEM_PULLREQUEST_TARGETBRANCH:-${BUILD_SOURCEBRANCH:-$(git -C "$repo_root" branch --show-current)}} -if [[ "$branch" =~ ^(refs/heads/)?release/ ]]; then - if [[ ! "$vs_minor" =~ ^[0-9]+$ ]]; then - echo "Release branches must pin VSMinorVersion in eng/Versions.props." >&2 - ExitWithExitCode 1 - fi - for property in ${properties[@]+"${properties[@]}"}; do - if [[ "$(echo "$property" | tr '[:upper:]' '[:lower:]')" == /p:vsminorversion=* && "${property#*=}" != "$vs_minor" ]]; then - echo "VSMinorVersion must match the release branch's pin ($vs_minor)." >&2 - ExitWithExitCode 1 - fi - done -fi -if [[ "$vs_minor" == UNSET ]]; then - vs_minor=$("$DOTNET_INSTALL_DIR/dotnet" fsi "$scriptroot/scripts/GetVSMinorVersion.fsx" "${VSBUILDDATEUTC:-$(date -u +%F)}") || ExitWithExitCode $? -fi -export VSMinorVersion="$vs_minor" - # Resolve product TFM from centralized source of truth if not overridden via --tfm if [[ "$tfm" == "" ]]; then tfm=$("$DOTNET_INSTALL_DIR/dotnet" msbuild "$scriptroot/TargetFrameworks.props" -getProperty:FSharpNetCoreProductTargetFramework 2>/dev/null | tr -d '[:space:]') diff --git a/eng/tests/VSMinorVersion.Tests.fsx b/eng/tests/VSMinorVersion.Tests.fsx deleted file mode 100644 index 5a1ac46e2bc..00000000000 --- a/eng/tests/VSMinorVersion.Tests.fsx +++ /dev/null @@ -1,64 +0,0 @@ -open System -open System.Diagnostics -open System.Globalization -open System.IO -open System.Text.Json - -let root = Path.GetFullPath(Path.Combine(__SOURCE_DIRECTORY__, "../..")) - -let run minor arguments = - let start = ProcessStartInfo(Environment.ProcessPath, RedirectStandardOutput = true, RedirectStandardError = true) - start.Environment["VSMinorVersion"] <- minor - for argument in arguments do - start.ArgumentList.Add argument - use child = Process.Start start - let output, errors = child.StandardOutput.ReadToEndAsync(), child.StandardError.ReadToEndAsync() - child.WaitForExit() - child.ExitCode, output.Result.Trim(), errors.Result - -for minor, text in - [ 0, "2025-08-29"; 1, "2025-10-03"; 2, "2025-10-31"; 3, "2025-11-28" - 4, "2026-01-02"; 5, "2026-01-30"; 6, "2026-02-27"; 7, "2026-04-03" - 8, "2026-05-01"; 9, "2026-05-29"; 10, "2026-07-03"; 11, "2026-07-31" - 12, "2026-08-28"; 13, "2026-10-02"; 14, "2026-10-30"; 15, "2026-11-27" ] do - let friday = DateOnly.Parse(text, CultureInfo.InvariantCulture) - for days in (if minor = 0 then [ 0; 4 ] else [ -1; 0; 4 ]) do - let date = friday.AddDays(days).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) - let expected = minor - (if days < 0 then 1 else 0) - let code, output, errors = run "" [ "fsi"; Path.Combine(root, "eng/scripts/GetVSMinorVersion.fsx"); date ] - if code <> 0 || output <> string expected then - failwith $"{date}: expected {expected}, got {output}. {errors}" - -let fixture = Path.Combine(Path.GetTempPath(), $"fsharp-vs-version-{Guid.NewGuid():N}") -Directory.CreateDirectory fixture |> ignore -try - let project = Path.Combine(fixture, "Version.fsproj") - let source = Path.Combine(fixture, "Assembly.fsx") - File.WriteAllText(project, $""" - - {root}/trueF# - - - - """) - for usesVS, minor, shouldFail in [ false, "", false; true, "", true; true, "UNSET", true; true, "13", false ] do - let code, output, errors = - run minor [ "msbuild"; project; "-nologo"; $"-p:UseVsMicroBuildAssemblyVersion={usesVS}" - "-getProperty:AssemblyVersion,NoWarn,WarningsAsErrors" ] - if code <> 0 then failwith errors - use data = JsonDocument.Parse output - let property (name: string) = data.RootElement.GetProperty("Properties").GetProperty(name).GetString() - let assemblyVersion = property "AssemblyVersion" - let noWarn = (property "NoWarn").Replace(';', ',') - let warningsAsErrors = (property "WarningsAsErrors").Replace(';', ',') - File.WriteAllText(source, $"open System.Reflection\n[]\ndo ()") - let code, _, errors = - run "" [ "fsi" - if noWarn <> "" then $"--nowarn:{noWarn}" - $"--warnaserror:{warningsAsErrors}"; source ] - if (code <> 0) <> shouldFail || (shouldFail && not (errors.Contains("FS2003", StringComparison.Ordinal))) then - failwith $"VS consumer={usesVS}, minor='{minor}': unexpected exit {code}. {errors}" -finally - Directory.Delete(fixture, true) - -printfn "VS minor version tests passed." From 80af3411980cc9e8a9039b65659d35859d9bd943 Mon Sep 17 00:00:00 2001 From: Adam Boniecki <20281641+abonie@users.noreply.github.com> Date: Thu, 12 Nov 2026 16:39:37 +0100 Subject: [PATCH 6/6] Add skill for creating VS backport branches Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/skills/create-vs-backport/SKILL.md | 227 +++++++++++++++++++++ 1 file changed, 227 insertions(+) create mode 100644 .github/skills/create-vs-backport/SKILL.md diff --git a/.github/skills/create-vs-backport/SKILL.md b/.github/skills/create-vs-backport/SKILL.md new file mode 100644 index 00000000000..40838d09b80 --- /dev/null +++ b/.github/skills/create-vs-backport/SKILL.md @@ -0,0 +1,227 @@ +--- +name: create-vs-backport +description: Create an F# backport or servicing branch for a Visual Studio release. Trace the target VS branch's inserted F# payload to its exact source commit, configure insertion mappings, and pin the VS minor version. +--- + +# Create a Visual Studio backport branch + +Start from the **F# source commit that produced the payload currently inserted +into the target VS branch**. Do not start from today's F# main, the latest +successful build, the VS insertion commit, or an estimated snap date. + +This workflow prepares a branch and configuration edits. It does not commit, +push, cherry-pick fixes, create a PR, queue a build, or insert into VS unless +the user also requests those actions. + +## Required inputs and access + +Obtain these inputs before making changes; ask for missing release decisions: + +- Target VS branch, without `refs/heads/`, for example `rel/insiders`. +- New F# release branch, for example `release/dev18.11`. Use `release/*` so CI + triggers and the release-branch handling in `eng\Build.ps1` apply. +- Target VS minor, explicitly confirmed by the user or release owner. Require + an integer in `0..65534`. Do not infer it from the branch name, today's date, + or the currently inserted payload's version. Two VS branches can share a minor. +- Approved F# checkout and, optionally, an existing VS clone. + +Require Git, PowerShell, Azure CLI authentication, and permission to read both +DevDiv's VS repository and dnceng/internal F# build metadata. Authentication +alone does not grant these permissions. Report access failures; never substitute +public PR builds or guessed source commits. + +Respect the host's workspace and branch-management rules. Do not switch a +shared/main checkout or bypass app-managed branch naming. If the host cannot +create the requested branch at the exact source SHA, report that limitation and +the required operation instead of creating a differently based/named branch. + +Check `$LASTEXITCODE` after every native command; PowerShell's +`$ErrorActionPreference = 'Stop'` does not make native-command failures fatal. + +## 1. Freeze the target VS snapshot + +Refresh the target branch from its verified VS remote without changing the VS +checkout. Resolve its fetched remote-tracking ref to a full commit SHA and keep +that SHA as `$VSSnapshot` for every subsequent read. Do not trust a stale local +branch or a detached HEAD as the current release state. + +If no VS clone is available, use an invocation-owned temporary directory and +an authenticated shallow clone of: + +```text +https://devdiv.visualstudio.com/DefaultCollection/DevDiv/_git/VS +``` + +Clone only the requested branch with `--no-checkout --single-branch --no-tags +--depth=100`. Deepen if needed; do not check out the large VS tree. + +Use the configured credential manager where possible. If bearer authentication +is needed for Git, supply it through process-scoped `GIT_CONFIG_*` settings for +`http.extraHeader`, disable Git tracing, and restore the previous settings in +`finally`. Never put tokens in command arguments, URLs, persistent Git config, +files, or output. Delete only temporary clones created by this invocation, +including on failure; never remove a supplied VS clone. + +Read the pinned snapshot's `.corext/Configs/components.json` and extract +`Components."Microsoft.FSharp"`: + +```powershell +$ComponentsPath = '.corext/Configs/components.json' # Git tree path +$components = (git -C $VSRepo show "${VSSnapshot}:$ComponentsPath") -join "`n" | + ConvertFrom-Json +$component = $components.Components.'Microsoft.FSharp' +$PayloadUrl = $component.url +``` + +Require a nonempty component URL. Record the component version if present, but +do not use that version to choose the target release minor. + +Locate the first-parent change that introduced the current URL: + +```powershell +git -C $VSRepo log $VSSnapshot --first-parent -n 1 --format=%H "-S$PayloadUrl" -- $ComponentsPath +``` + +Inspect the candidate and its first parent: the F# component must acquire the +current payload at that change. Record the insertion SHA, date, and subject. +A shallow boundary can look like an introduction because Git treats it as a +root. Check `git rev-parse --git-path shallow`; reject candidates at a boundary +or with an unavailable parent. Deepen the target branch and retry with a bounded +number of attempts, retaining `$VSSnapshot`. If provenance remains unprovable, +stop and request sufficient history rather than using the boundary as evidence. + +## 2. Resolve the payload to an F# build and source SHA + +The established payload URL contains +`/dotnet-fsharp//;...`. Preserve slashes within the +source branch and URI-decode it: + +```powershell +$match = [regex]::Match( + $PayloadUrl, + '/dotnet-fsharp/(?.+)/(?[^/;]+);', + [System.Text.RegularExpressions.RegexOptions]::IgnoreCase) +if (-not $match.Success) { throw 'Unrecognized F# payload URL; inspect its metadata.' } +$SourceBranch = [Uri]::UnescapeDataString($match.Groups['sourceBranch'].Value) +$BuildNumber = $match.Groups['buildNumber'].Value +``` + +Query the official `fsharp-ci` build using **both** the exact build number and +`refs/heads/$SourceBranch`. Its established definition ID is `499` in +`https://dev.azure.com/dnceng/internal`; verify the pipeline identity rather +than silently accepting a different definition if the configuration has moved. + +For Azure DevOps REST authentication, acquire a token into a variable without +printing it: + +```powershell +$token = az account get-access-token --resource 499b84ac-1321-427f-aa17-267ca6975798 --query accessToken --only-show-errors -o tsv +if ($LASTEXITCODE -ne 0 -or -not $token) { throw 'Azure DevOps authentication failed.' } +try { + $number = [Uri]::EscapeDataString($BuildNumber) + $branch = [Uri]::EscapeDataString("refs/heads/$SourceBranch") + $uri = "https://dev.azure.com/dnceng/internal/_apis/build/builds?definitions=499&buildNumber=$number&branchName=$branch&api-version=7.1" + $response = Invoke-RestMethod -Uri $uri -Headers @{ Authorization = "Bearer $token" } +} +finally { + Remove-Variable token -ErrorAction SilentlyContinue +} +``` + +Require exactly one matching build, handling pagination if returned. Verify its +definition, build number, source branch, and expected F# repository or official +mirror. Use its `sourceVersion` as `$SourceSha`, requiring a full 40-digit +hexadecimal Git commit SHA. Record the build ID as well as its number. + +Resolve that commit in the approved F# checkout. Inspect remote URLs; prefer a +verified `upstream`, then `origin`, containing the recorded source branch. +Fetch the exact branch if cached refs do not contain the commit. Deepen a +shallow clone when necessary; do not change the checkout during discovery. + +Prove that `$SourceSha` exists as a commit and is an ancestor of the resolved +source-branch ref using `git cat-file -e` and `git merge-base --is-ancestor`. +If the commit or ancestry cannot be verified, stop. The branch tip is evidence +of ancestry, **not** the branch point. + +## 3. Preflight and present the plan + +Before creating anything: + +- Validate the proposed branch with `git check-ref-format --branch`. Check + local branches, other worktrees, and the intended publishing remote for an + existing branch. Do not reset or overwrite one; ask whether to use it. +- Require a clean destination worktree. Do not automatically stash, discard, + or commit user changes. Protect untracked and ignored paths from collisions. +- Read `azure-pipelines.yml`, `eng\Versions.props`, and the applicable build + entry points **at `$SourceSha`**, not just in the current checkout. +- Confirm each pipeline mapping exists once and that the version layout can + be pinned unambiguously. If the historical layout differs, explain the + required adaptation rather than replacing whole files with today's copies. + +Present the VS branch and snapshot, current payload identity, insertion commit, +F# build ID/number and source branch, exact F# source SHA, new branch name, +confirmed minor, and the two planned file edits. Redact credentials or signed +URL parameters. If the user requested only discovery/preview, stop here. +Otherwise proceed only within their authorization to create this branch. + +## 4. Create the branch and configure servicing + +In an approved standalone checkout, the Git operation is: + +```powershell +git -C $FSharpRepo switch --no-overwrite-ignore -c $NewFSharpBranch $SourceSha +``` + +For a managed workspace, use its supported equivalent with the same exact base +and branch name. Do not merge main into the new branch. + +Make only these configuration changes, preserving formatting and unrelated +content: + +1. In `azure-pipelines.yml`, set `FSharpReleaseBranchName` to the new **F#** + branch and `VSInsertionTargetBranchName` to the target **VS** branch, both + without `refs/heads/`. These are different mappings: they control which F# + branch can insert and where its payload is sent. +2. In `eng\Versions.props`, set one **unconditional literal** + `11` using the confirmed minor, not + necessarily `11`. Replace any conditional `UNSET` default, including its + `Condition`; merely changing its text would still allow an environment + value to win. Preserve `VSMajorVersion` unless a major-version change is + explicitly part of the release decision. + +The date-based `eng\Build.ps1` implementation skips automatic minor selection +for `release/*`, including PRs targeting a release branch. The unconditional +pin also protects direct MSBuild/IDE builds from inherited environment values. +If the historical base instead has `UseVSScheduledMinorVersion` and a schedule +import, disable that existing mechanism before its import. Do not add that +removed property or copy the removed backport utility into the newer layout. + +## 5. Verify and hand off + +Before any commit, verify `HEAD` is still exactly `$SourceSha`. Run +`git diff --check` and inspect the diff: branch preparation should change only +the two configuration files above. + +Evaluate the actual pinned properties without compiling: + +```powershell +dotnet msbuild .\eng\Versions.props -nologo -getProperty:VSMajorVersion,VSMinorVersion,VSAssemblyVersion +``` + +For major `18` and minor `11`, expect `VSMinorVersion=11` and +`VSAssemblyVersion=18.11.0.0`. Repeat in a child process with the +`VSMinorVersion` environment variable set to a different number; the pin must +still win. If the base has date/timestamp-driven versioning, also evaluate with +a later date/timestamp to prove the release remains pinned. Do not test this +using `/p:VSMinorVersion=...`: an MSBuild command-line global property overrides +even an unconditional project assignment. + +Confirm both insertion mappings, the `release/*` trigger coverage, and the +absence of unintended compiler/Core/FCS version changes. Do not run a signed +build or an insertion merely to validate branch preparation. + +Report the created branch, exact base SHA and build provenance, fixed VS +version, insertion target, and whether edits remain uncommitted. If creation +succeeds but edits fail, report the partial state explicitly; do not perform a +destructive automatic rollback. Clean up invocation-owned temporary clones and +leave unrelated checkouts and credentials unchanged.