From e8b494349cb5fe01da4d541fe837daee09dae9e5 Mon Sep 17 00:00:00 2001 From: Tomas Grosup Date: Thu, 6 Aug 2026 13:13:03 +0200 Subject: [PATCH 1/2] Rework UseLocalCompiler shim into 3 gates for regression FSharp.Core Gate 1 (LoadLocalFSharpBuild): redirect compiler/build tasks/targets to the local build and carry --nowarn:75 --times (moved from the prepare fsx). Gate 2 (RegressionLocalCore, matrix-only): serve the locally built FSharp.Core the SDK's own way via FSCorePackageVersion plus a staged library-packs restore source, so each repo resolves the ns2.0/ns2.1 core it would normally get. Only engages when the repo has not pinned FSharp.Core (FSharpCoreImplicitPackageVersion empty and FSCorePackageVersionSet not already set), so pinned/CPM/Update repos are untouched. We deliberately don't set FSharpCoreImplicitPackageVersion so a repo's own conditional pin keeps working. Gate 3 (LoadLocalFSharpBuild without the matrix, i.e. CompilerCompat/VS): keep the direct local FSharp.Core reference. Previously this and DisableImplicitFSharpCoreReference were unconditional, which suppressed the implicit reference for the matrix too. Set LoadLocalFSharpBuild=True in the VS integration standalone project so it keeps using the local compiler now that the redirect is gated. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- UseLocalCompiler.Directory.Build.props | 51 ++++++++++++------- UseLocalCompiler.Directory.Build.targets | 11 ++++ .../InProcess/SolutionExplorerInProcess.cs | 1 + 3 files changed, 46 insertions(+), 17 deletions(-) create mode 100644 UseLocalCompiler.Directory.Build.targets diff --git a/UseLocalCompiler.Directory.Build.props b/UseLocalCompiler.Directory.Build.props index 8bd796bfe90..ac526954c57 100644 --- a/UseLocalCompiler.Directory.Build.props +++ b/UseLocalCompiler.Directory.Build.props @@ -7,39 +7,56 @@ False - Release + $(MSBuildThisFileDirectory) + - $(MSBuildThisFileDirectory) - - true + + true $([System.IO.Path]::GetDirectoryName($(DOTNET_HOST_PATH))) $([System.IO.Path]::GetFileName($(DOTNET_HOST_PATH))) - - $(LocalFSharpCompilerPath)/artifacts/bin/fsc/$(LocalFSharpCompilerConfiguration)/$(FSharpNetCoreProductTargetFramework)/fsc.dll - $(LocalFSharpCompilerPath)/artifacts/bin/fsc/$(LocalFSharpCompilerConfiguration)/$(FSharpNetCoreProductTargetFramework)/fsc.dll - False True - - - $(LocalFSharpCompilerPath)/artifacts/bin/fsc/$(LocalFSharpCompilerConfiguration)/$(FSharpNetCoreProductTargetFramework) + $(LocalFSharpBuildBinPath)/fsc.dll + $(LocalFSharpBuildBinPath)/fsc.dll $(LocalFSharpBuildBinPath)/FSharp.Build.dll $(LocalFSharpBuildBinPath)/Microsoft.FSharp.Targets $(LocalFSharpBuildBinPath)/Microsoft.FSharp.NetSdk.props $(LocalFSharpBuildBinPath)/Microsoft.FSharp.NetSdk.targets $(LocalFSharpBuildBinPath)/Microsoft.FSharp.Overrides.NetSdk.targets + + $(OtherFlags) --nowarn:75 --times + + + + + $(RegressionLocalCoreVersion) + true + <_FSharpCoreLibraryPacksFolder>$(MSBuildThisFileDirectory)library-packs - + + + + + + + true + + diff --git a/UseLocalCompiler.Directory.Build.targets b/UseLocalCompiler.Directory.Build.targets new file mode 100644 index 00000000000..0779e90aa02 --- /dev/null +++ b/UseLocalCompiler.Directory.Build.targets @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/vsintegration/tests/FSharp.Editor.IntegrationTests/InProcess/SolutionExplorerInProcess.cs b/vsintegration/tests/FSharp.Editor.IntegrationTests/InProcess/SolutionExplorerInProcess.cs index 43743484e96..7b330eddc6f 100644 --- a/vsintegration/tests/FSharp.Editor.IntegrationTests/InProcess/SolutionExplorerInProcess.cs +++ b/vsintegration/tests/FSharp.Editor.IntegrationTests/InProcess/SolutionExplorerInProcess.cs @@ -60,6 +60,7 @@ private static string CreateStandaloneProjectFile() return $@" + True Debug {RepoRoot} From 1077063763e1bfaf3518723988f599ef7f2abe66 Mon Sep 17 00:00:00 2001 From: Tomas Grosup Date: Thu, 6 Aug 2026 16:17:35 +0200 Subject: [PATCH 2/2] Regression matrix: run unmodified repos against the local FSharp.Core Add eng/scripts/BuildWithLocalFSharp.fsx, an OS-agnostic runner (dotnet fsi is the only dependency) that CI and a contributor invoke identically: it discovers the freshly packed FSharp.Core, exports the environment the 3-gate UseLocalCompiler props reads, then runs the repo's own build command from its own directory (';;'-separated, fail-fast; per-OS shell; never chmod or edit the checkout). With --verify it asserts the local core was actually restored. A failed regression therefore reproduces locally with one command, no pipeline. - Delete eng/scripts/PrepareRepoForRegressionTesting.fsx: nothing rewrites the target repo anymore; the shim is injected only via CustomAfterDirectoryBuildProps. - eng/templates/regression-test-jobs.yml: replace the inline build logic with a single fsx call, pin global.json to the exact compiler SDK, and on failure print an OS-agnostic local-repro block. No pwsh-specific build logic remains. - azure-pipelines-PR.yml: stage the packed FSharp.Core into the existing UseLocalCompilerProps artifact and mark the implicit-core matrix entries expectLocalCore: true; 120-minute timeout. - docs + compiler-perf-investigator agent: document the fsx-based OS-agnostic local workflow. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 39f45ce2-37e7-4026-b55a-e09360417611 --- .github/agents/compiler-perf-investigator.md | 2 +- UseLocalCompiler.Directory.Build.props | 17 +- azure-pipelines-PR.yml | 22 ++- docs/regression-testing-pipeline.md | 53 ++---- eng/scripts/BuildWithLocalFSharp.fsx | 118 +++++++++++++ .../PrepareRepoForRegressionTesting.fsx | 110 ------------ eng/templates/regression-test-jobs.yml | 163 ++++-------------- 7 files changed, 196 insertions(+), 289 deletions(-) create mode 100644 eng/scripts/BuildWithLocalFSharp.fsx delete mode 100644 eng/scripts/PrepareRepoForRegressionTesting.fsx diff --git a/.github/agents/compiler-perf-investigator.md b/.github/agents/compiler-perf-investigator.md index 716821d5ae9..5ad3c9477d0 100644 --- a/.github/agents/compiler-perf-investigator.md +++ b/.github/agents/compiler-perf-investigator.md @@ -40,7 +40,7 @@ These are **general investigation instructions** for this agent, a template for ### 1. Preparation - **Setup:** Clone/generate repo/snippet/etc. - **Clear old config:** Remove `global.json` unless needed. -- **Prepare local compiler:** Use `PrepareRepoForRegressionTesting.fsx` and absolute env paths. +- **Prepare local compiler:** Build via `dotnet fsi /eng/scripts/BuildWithLocalFSharp.fsx --build-script ''` (sets the local-compiler + FSharp.Core shim env). ### 2. Experiment Matrix diff --git a/UseLocalCompiler.Directory.Build.props b/UseLocalCompiler.Directory.Build.props index ac526954c57..6cfa1da98bc 100644 --- a/UseLocalCompiler.Directory.Build.props +++ b/UseLocalCompiler.Directory.Build.props @@ -32,17 +32,13 @@ $(OtherFlags) --nowarn:75 --times - + $(RegressionLocalCoreVersion) true - <_FSharpCoreLibraryPacksFolder>$(MSBuildThisFileDirectory)library-packs + <_FSharpCoreLibraryPacksFolder>$([MSBuild]::ValueOrDefault('$(RegressionLocalCorePackagesDir)', '$(MSBuildThisFileDirectory)library-packs')) - + true diff --git a/azure-pipelines-PR.yml b/azure-pipelines-PR.yml index 65f45277382..ff6f4603975 100644 --- a/azure-pipelines-PR.yml +++ b/azure-pipelines-PR.yml @@ -583,13 +583,19 @@ stages: condition: succeeded() - pwsh: | - # Stage UseLocalCompiler props and TargetFrameworks.props together + # Stage the props and the locally built FSharp.Core so regression jobs restore it the SDK way. + # Arcade packs FSharp.Core into a `Shipping` leaf whose parent varies by layout, so search for it. $stagingDir = "$(Build.SourcesDirectory)/UseLocalCompilerPropsStaging" - New-Item -ItemType Directory -Force -Path $stagingDir | Out-Null + $packsDir = Join-Path $stagingDir "library-packs" + New-Item -ItemType Directory -Force -Path $packsDir | Out-Null Copy-Item "$(Build.SourcesDirectory)/UseLocalCompiler.Directory.Build.props" -Destination $stagingDir Copy-Item "$(Build.SourcesDirectory)/eng/TargetFrameworks.props" -Destination $stagingDir + $core = Get-ChildItem "$(Build.SourcesDirectory)/artifacts/packages/Release" -Recurse -Filter "FSharp.Core.*.nupkg" | + Where-Object { $_.Name -notlike "*.symbols.nupkg" -and $_.Directory.Name -eq "Shipping" } | Select-Object -First 1 + if (-not $core) { Write-Host "##[error]FSharp.Core.*.nupkg not found under artifacts/packages/Release/**/Shipping"; exit 1 } + Copy-Item $core.FullName -Destination $packsDir Write-Host "Staged files for UseLocalCompilerProps artifact:" - Get-ChildItem $stagingDir -Name + Get-ChildItem $stagingDir -Recurse -Name displayName: Stage UseLocalCompiler props files - task: PublishPipelineArtifact@1 @@ -749,6 +755,7 @@ stages: commit: bbe2dec4d0379b5d7d0480997858c30d442fbb42 buildScript: dotnet build -bl displayName: UMX_Slow_Repro + expectLocalCore: true - repo: fsprojects/FSharpPlus commit: f614035b75922aba41ed6a36c2fc986a2171d2b8 buildScript: build.cmd @@ -763,6 +770,7 @@ stages: commit: 2648efe buildScript: dotnet build tests/FSharpPlus.Tests/FSharpPlus.Tests.fsproj -c Release -bl displayName: FsharpPlus_NET10_Build_Lib_Tests + expectLocalCore: true # remove this before merging - repo: fsprojects/FSharpPlus commit: 2648efe @@ -814,10 +822,18 @@ stages: commit: 6ddc28d46f81447eacb241b96e16ce693b210c96 buildScript: dotnet build Prime.sln --configuration Release displayName: Prime_Build + expectLocalCore: true - repo: bryanedds/Nu commit: e81e00a464b9d35d272f61708a1a0bfbf487b6d5 buildScript: dotnet build Nu.sln --configuration Release displayName: Nu_Build + expectLocalCore: true + # Explicit FSharp.Core PackageReference mixed with one implicit test project; the trigger for #20059 (Lanayx/Oxpecker#20065). + - repo: Lanayx/Oxpecker + commit: cb7e4b83e3f2aba7ded46b17a36d1aea8c13c292 + buildScript: dotnet build Oxpecker.slnx -c Release + displayName: Oxpecker_Build + expectLocalCore: true # Design-time provider packaging oracle. Pin d8aba70 (pre-workaround base of FSharp.Data.GraphQL#583): # it uses the bare IsFSharpDesignTimeProvider gesture, so the client pack drops the provider without this fix. # Linux-only: the $PWD local feed and the grep content assertion need bash. (nupkg entry names are stored diff --git a/docs/regression-testing-pipeline.md b/docs/regression-testing-pipeline.md index 5e3013bfad5..7c0518f06ea 100644 --- a/docs/regression-testing-pipeline.md +++ b/docs/regression-testing-pipeline.md @@ -25,8 +25,8 @@ The regression testing logic is implemented as a reusable Azure DevOps template 1. **Build F# Compiler**: The `EndToEndBuildTests` job builds the F# compiler and publishes required artifacts 2. **Matrix Execution**: For each library in the test matrix (running in parallel): - Checkout the third-party repository at a specific commit - - Install appropriate .NET SDK version using the repository's `global.json` - - Setup `Directory.Build.props` to import `UseLocalCompiler.Directory.Build.props` + - Pin `global.json` to the exact SDK that built the local compiler + - Inject `UseLocalCompiler.Directory.Build.props` via `CustomAfterDirectoryBuildProps` - Build the library using its standard build script - Publish MSBuild binary logs for analysis 3. **Report Results**: Success/failure status is reported with build logs for diagnosis @@ -68,8 +68,9 @@ To add a new library to the test matrix, update the template invocation in `azur Each test matrix entry requires: - **repo**: GitHub repository in `owner/name` format - **commit**: Specific commit SHA for reproducible results -- **buildScript**: Build script to execute (e.g., `build.cmd`, `build.sh`) +- **buildScript**: Build command to execute — a `dotnet ...` command or a script file (`build.cmd`/`build.sh`); `;;` separates commands run sequentially, fail-fast - **displayName**: Human-readable name for the job +- **expectLocalCore** (optional): set `true` when the repo has projects that take the implicit `FSharp.Core`; the job then fails unless the locally built FSharp.Core is actually restored — a tripwire for a silently broken shim ## Pipeline Configuration @@ -82,10 +83,10 @@ Regression tests run automatically as part of PR builds when: ### Build Environment -- **OS**: Windows (using `$(WindowsMachineQueueName)`) +- **OS**: Windows by default (`$(WindowsMachineQueueName)`); matrix entries can override to Linux. The scripts are OS-agnostic - **Pool**: Standard public build pool (`$(DncEngPublicBuildPool)`) -- **Timeout**: 60 minutes per regression test job -- **.NET SDK**: Automatically detects and installs SDK version from each repository's `global.json` +- **Timeout**: 120 minutes per regression test job +- **.NET SDK**: Each test repo's `global.json` is pinned to the SDK that built the local compiler, so `fsc.dll` and its host runtime line up ### Artifacts @@ -108,26 +109,19 @@ When a regression test fails: ### Local Testing -To test a library locally with your F# compiler build: +To reproduce a regression locally, on any OS, without editing the library: -1. Build the F# compiler: `.\Build.cmd -c Release -pack` - -2. In the third-party library directory, create a `Directory.Build.props`: - ```xml - - - +1. Build the compiler and pack FSharp.Core in your `dotnet/fsharp` checkout: `./build.sh -c Release -pack` (`Build.cmd` on Windows). +2. Clone the library at the failing commit and build it against your local build: ``` - -3. Update the `LocalFSharpCompilerPath` in `UseLocalCompiler.Directory.Build.props` to point to your F# repository. - -4. Set environment variables: - ```cmd - set LoadLocalFSharpBuild=true - set LocalFSharpCompilerConfiguration=Release + git clone --recursive https://github.com//.git TestRepo + cd TestRepo && git checkout + # If TestRepo's global.json pins a different SDK, align sdk.version with /global.json + # (allowPrerelease: true, rollForward: disable) — the clone is disposable, as in CI. + dotnet fsi /eng/scripts/BuildWithLocalFSharp.fsx --build-script '' ``` -5. Run the library's build script. +`BuildWithLocalFSharp.fsx` runs the same command CI runs, from the current directory, without touching the repo's sources. Add `--verify` to fail unless every project consumes the local FSharp.Core; the script header lists the other options. Because the local package keeps a fixed `-dev` version, the script evicts it from the global NuGet cache before each run so a rebuild is never served stale; pass `--nuget-packages ` to use an isolated cache when running several builds concurrently or against a repo that redirects its packages folder. ## Best Practices @@ -148,20 +142,7 @@ To test a library locally with your F# compiler build: ### UseLocalCompiler.Directory.Build.props -This MSBuild props file configures projects to use the locally built F# compiler instead of the SDK version. Key settings: - -- `LocalFSharpCompilerPath`: Points to the F# compiler artifacts -- `DotnetFscCompilerPath`: Path to the fsc.dll compiler -- `DisableImplicitFSharpCoreReference`: Ensures local FSharp.Core is used - -### Path Handling - -The pipeline dynamically updates paths in the props file using PowerShell: -```powershell -$content -replace 'LocalFSharpCompilerPath.*MSBuildThisFileDirectory.*', 'LocalFSharpCompilerPath>$(Pipeline.Workspace)/FSharpCompiler<' -``` - -This ensures the correct path is used in the Azure DevOps environment. +This MSBuild props file redirects projects to the locally built F# compiler (and, for the matrix, the locally built FSharp.Core) instead of the SDK version. It is organised into gates so it can be injected into unmodified repos as well as imported directly by in-repo tests — see the `Gate 1/2/3` comments in the file. Its companion `UseLocalCompiler.Directory.Build.targets` is injected via `CustomAfterDirectoryBuildTargets` (after the target repo's project body) so the local FSharp.Core version wins over the repo's own reference, whether implicit, an explicit `PackageReference Include`, a `PackageReference Update`, or a central `PackageVersion` (Central Package Management). ## Future Enhancements diff --git a/eng/scripts/BuildWithLocalFSharp.fsx b/eng/scripts/BuildWithLocalFSharp.fsx new file mode 100644 index 00000000000..d01aaeca138 --- /dev/null +++ b/eng/scripts/BuildWithLocalFSharp.fsx @@ -0,0 +1,118 @@ +// Build an unmodified repo with this checkout's F# compiler and FSharp.Core, on any OS (needs only the .NET SDK). +// dotnet fsi /eng/scripts/BuildWithLocalFSharp.fsx --build-script "dotnet build MySolution.sln" +// Prerequisite: build this checkout with `-c Release -pack`. + +open System +open System.IO +open System.Diagnostics + +let fail (msg: string) : 'a = eprintfn "ERROR: %s" msg; exit 1 + +let opts = System.Collections.Generic.Dictionary(StringComparer.OrdinalIgnoreCase) + +let rec parseArgs = function + | (key: string) :: value :: rest when key.StartsWith "--" && not (value.StartsWith "--") -> + opts.[key.Substring 2] <- value + parseArgs rest + | key :: rest when key.StartsWith "--" -> + opts.[key.Substring 2] <- "true" + parseArgs rest + | _ :: rest -> parseArgs rest + | [] -> () + +fsi.CommandLineArgs |> Array.tail |> Array.toList |> parseArgs + +let tryOpt k = match opts.TryGetValue k with | true, v -> Some v | _ -> None +let opt k d = defaultArg (tryOpt k) d + +let fsharpRoot = opt "fsharp-root" (Path.GetFullPath(Path.Combine(__SOURCE_DIRECTORY__, "..", ".."))) +let configuration = opt "configuration" "Release" +let compilerPath = opt "compiler-path" fsharpRoot +let props = opt "props" (Path.Combine(fsharpRoot, "UseLocalCompiler.Directory.Build.props")) +let targets = opt "targets" (Path.Combine(fsharpRoot, "UseLocalCompiler.Directory.Build.targets")) +let corePackagesDir = opt "core-packages-dir" (Path.Combine(compilerPath, "artifacts", "packages", configuration)) +let repoDir = opt "repo-dir" (Directory.GetCurrentDirectory()) +let buildScript = match tryOpt "build-script" with Some s -> s | None -> fail "--build-script is required" +let verify = (tryOpt "verify").IsSome + +if not (File.Exists props) then fail (sprintf "props file not found: %s" props) +if not (File.Exists targets) then fail (sprintf "targets file not found: %s" targets) +if not (Directory.Exists corePackagesDir) then + fail (sprintf "FSharp.Core package folder not found: %s (build the compiler with `-c %s -pack`)" corePackagesDir configuration) + +let nupkg = + // Arcade routes FSharp.Core to a `Shipping` leaf that varies by layout (Release/Shipping locally, + // Dependency/Shipping on CI), so search recursively and prefer that folder, then newest. + Directory.GetFiles(corePackagesDir, "FSharp.Core.*.nupkg", SearchOption.AllDirectories) + |> Array.filter (fun f -> not (f.EndsWith(".symbols.nupkg", StringComparison.OrdinalIgnoreCase))) + |> Array.sortByDescending (fun f -> Path.GetFileName(Path.GetDirectoryName f) = "Shipping", File.GetLastWriteTimeUtc f) + |> Array.tryHead + |> Option.defaultWith (fun () -> fail (sprintf "no FSharp.Core.*.nupkg under %s" corePackagesDir)) + +let version = Path.GetFileNameWithoutExtension(nupkg).Substring("FSharp.Core.".Length) + +let setEnv k v = Environment.SetEnvironmentVariable(k, v) +setEnv "LoadLocalFSharpBuild" "True" +setEnv "LocalFSharpCompilerPath" compilerPath +setEnv "LocalFSharpCompilerConfiguration" configuration +setEnv "CustomAfterDirectoryBuildProps" props +setEnv "CustomAfterDirectoryBuildTargets" targets +setEnv "RegressionLocalCore" "true" +setEnv "RegressionLocalCoreVersion" version +setEnv "RegressionLocalCorePackagesDir" corePackagesDir +tryOpt "nuget-packages" |> Option.iter (setEnv "NUGET_PACKAGES") + +// NuGet caches by id+version, so a repacked same-version local FSharp.Core would be served stale; evict it first. +let globalPackages = + match Environment.GetEnvironmentVariable "NUGET_PACKAGES" with + | null | "" -> Path.Combine(Environment.GetFolderPath Environment.SpecialFolder.UserProfile, ".nuget", "packages") + | p -> p +let cachedCore = Path.Combine(globalPackages, "fsharp.core", version) +if Directory.Exists cachedCore then + try Directory.Delete(cachedCore, true) + with e -> eprintfn "WARN: could not evict cached %s: %s" cachedCore e.Message + +printfn "Local F# compiler: %s (%s)" compilerPath configuration +printfn "Local FSharp.Core: %s from %s" version corePackagesDir + +let run (command: string) = + let psi = ProcessStartInfo(WorkingDirectory = repoDir, UseShellExecute = false) + let launch = + if OperatingSystem.IsWindows() then + psi.FileName <- "cmd.exe" + psi.ArgumentList.Add "/c" + if command.StartsWith("dotnet", StringComparison.OrdinalIgnoreCase) then command else ".\\" + command + else + psi.FileName <- "/bin/bash" + psi.ArgumentList.Add "-c" + // Escape bare ';' so MSBuild's `-t:Build;Test` stays one argument, and run non-dotnet scripts + // through bash instead of `chmod +x` so the checked-out repo is never modified. + let escaped = command.Replace(";", "\\;") + if command.StartsWith("dotnet", StringComparison.OrdinalIgnoreCase) then escaped else "bash " + escaped + psi.ArgumentList.Add launch + printfn "==> %s" command + use p = Process.Start psi + p.WaitForExit() + p.ExitCode + +for cmd in buildScript.Split([| ";;" |], StringSplitOptions.RemoveEmptyEntries ||| StringSplitOptions.TrimEntries) do + let code = run cmd + if code <> 0 then fail (sprintf "build command failed with exit code %d" code) + +if verify then + // Fail if any project resolved a non-local FSharp.Core; match the exact quoted identity so a longer + // prerelease can't satisfy a prefix. + let rx = System.Text.RegularExpressions.Regex("\"FSharp\\.Core/([^\"]+)\"") + let options = EnumerationOptions(RecurseSubdirectories = true, IgnoreInaccessible = true) + let mutable usedLocal = false + let others = System.Collections.Generic.SortedSet() + for f in Directory.EnumerateFiles(repoDir, "project.assets.json", options) do + let text = try File.ReadAllText f with _ -> "" + for m in rx.Matches text do + if m.Groups.[1].Value = version then usedLocal <- true + else others.Add m.Groups.[1].Value |> ignore + if others.Count > 0 then + fail (sprintf "expected local FSharp.Core %s but some projects resolved: %s" version (String.Join(", ", others))) + if not usedLocal then + fail (sprintf "expected local FSharp.Core %s in project.assets.json but found none; built against a different FSharp.Core" version) + printfn "Verified: local FSharp.Core %s was consumed." version diff --git a/eng/scripts/PrepareRepoForRegressionTesting.fsx b/eng/scripts/PrepareRepoForRegressionTesting.fsx deleted file mode 100644 index e1df77bcb45..00000000000 --- a/eng/scripts/PrepareRepoForRegressionTesting.fsx +++ /dev/null @@ -1,110 +0,0 @@ -/// Script to inject UseLocalCompiler.Directory.Build.props import into a third-party repository's Directory.Build.props -/// Usage: dotnet fsi PrepareRepoForRegressionTesting.fsx - -open System -open System.IO -open System.Xml - -let propsFilePath = "Directory.Build.props" - -let useLocalCompilerPropsPath = - let args = Environment.GetCommandLineArgs() - // When running with dotnet fsi, args are: [0]=dotnet; [1]=fsi.dll; [2]=script.fsx; [3...]=args - let scriptArgs = args |> Array.skipWhile (fun a -> not (a.EndsWith(".fsx"))) |> Array.skip 1 - if scriptArgs.Length > 0 then - scriptArgs.[0] - else - failwith "Usage: dotnet fsi PrepareRepoForRegressionTesting.fsx " - -printfn "PrepareRepoForRegressionTesting.fsx" -printfn "===================================" -printfn "UseLocalCompiler props path: %s" useLocalCompilerPropsPath - -if not (File.Exists(useLocalCompilerPropsPath)) then - failwithf "UseLocalCompiler.Directory.Build.props not found at: %s" useLocalCompilerPropsPath - -printfn "✓ UseLocalCompiler.Directory.Build.props found" - -let absolutePropsPath = - Path.GetFullPath(useLocalCompilerPropsPath).Replace("\\", "/") -printfn "Absolute path: %s" absolutePropsPath - -if File.Exists(propsFilePath) then - printfn "Directory.Build.props exists, modifying it..." - - let doc = XmlDocument() - doc.PreserveWhitespace <- true - doc.Load(propsFilePath) - - let projectElement = doc.SelectSingleNode("/Project") - if isNull projectElement then - failwith "Could not find Project element in Directory.Build.props" - - let xpath = "//Import[contains(@Project, 'UseLocalCompiler.Directory.Build.props')]" - let existingImport = doc.SelectSingleNode(xpath) - - if isNull existingImport then - let importElement = doc.CreateElement("Import") - importElement.SetAttribute("Project", absolutePropsPath) - - if projectElement.HasChildNodes then - projectElement.InsertBefore(importElement, projectElement.FirstChild) |> ignore - else - projectElement.AppendChild(importElement) |> ignore - - let newline = doc.CreateTextNode("\n ") - projectElement.InsertAfter(newline, importElement) |> ignore - - doc.Save(propsFilePath) - printfn "✓ Added UseLocalCompiler import to Directory.Build.props" - else - printfn "✓ UseLocalCompiler import already exists" - - let otherFlagsWithTimes = doc.SelectSingleNode("//OtherFlags[contains(text(), '--times')]") - - if isNull otherFlagsWithTimes then - let propertyGroup = doc.CreateElement("PropertyGroup") - let otherFlags = doc.CreateElement("OtherFlags") - otherFlags.InnerText <- "$(OtherFlags) --nowarn:75 --times" - propertyGroup.AppendChild(otherFlags) |> ignore - - let importNode = doc.SelectSingleNode(xpath) - - // PreserveWhitespace=true causes XML DOM to keep text nodes (newlines/indentation) between elements; - // skip past the whitespace text node after the import to position the PropertyGroup correctly - let nodeAfterImport = - if not (isNull importNode) && not (isNull importNode.NextSibling) && importNode.NextSibling.NodeType = XmlNodeType.Text then - importNode.NextSibling - else - null - - if not (isNull nodeAfterImport) then - projectElement.InsertAfter(propertyGroup, nodeAfterImport) |> ignore - else - projectElement.InsertAfter(propertyGroup, importNode) |> ignore - - let newlineAfter = doc.CreateTextNode("\n ") - projectElement.InsertAfter(newlineAfter, propertyGroup) |> ignore - - doc.Save(propsFilePath) - printfn "✓ Added --times flag to OtherFlags" - else - if not (otherFlagsWithTimes.InnerText.Contains("--nowarn:75")) then - otherFlagsWithTimes.InnerText <- otherFlagsWithTimes.InnerText.Replace("--times", "--nowarn:75 --times") - doc.Save(propsFilePath) - printfn "✓ Added --nowarn:75 to existing OtherFlags" - else - printfn "✓ --times and --nowarn:75 already exist in OtherFlags" -else - printfn "Directory.Build.props does not exist, creating it..." - let newContent = sprintf "\n \n \n $(OtherFlags) --nowarn:75 --times\n \n\n" absolutePropsPath - File.WriteAllText(propsFilePath, newContent) - printfn "✓ Created Directory.Build.props with UseLocalCompiler import and --times flag" - -printfn "" -printfn "Final Directory.Build.props content:" -printfn "-----------------------------------" -let content = File.ReadAllText(propsFilePath) -printfn "%s" content -printfn "-----------------------------------" -printfn "✓ Repository prepared for regression testing" diff --git a/eng/templates/regression-test-jobs.yml b/eng/templates/regression-test-jobs.yml index ba7a3c19dab..829debeb3f4 100644 --- a/eng/templates/regression-test-jobs.yml +++ b/eng/templates/regression-test-jobs.yml @@ -65,41 +65,20 @@ jobs: Write-Host "Successfully checked out ${{ item.repo }} at commit ${{ item.commit }}" git log -1 --oneline - + Write-Host "Repository structure:" Get-ChildItem -Name - - $buildScript = '${{ item.buildScript }}' - # Support ';;' separator for multiple commands — validate each command's script file - $commands = $buildScript -split ';;' | ForEach-Object { $_.Trim() } | Where-Object { $_ } - foreach ($cmd in $commands) { - if ($cmd -like "dotnet*") { - Write-Host "Built-in dotnet command, skipping file check: $cmd" - } else { - $scriptFile = ($cmd -split ' ', 2)[0] - Write-Host "Verifying build script exists: $scriptFile" - if (Test-Path $scriptFile) { - Write-Host "Build script found: $scriptFile" - } else { - Write-Host "Build script not found: $scriptFile" - Write-Host "Available files in root:" - Get-ChildItem - exit 1 - } - } - } displayName: Checkout ${{ item.displayName }} at specific commit - pwsh: | Set-Location $(Pipeline.Workspace)/TestRepo - Write-Host "Removing global.json to use latest SDK..." - if (Test-Path "global.json") { - Remove-Item "global.json" -Force - Write-Host "global.json removed" - } else { - Write-Host "No global.json found" - } - displayName: Remove global.json to use latest SDK + # Pin the test repo to the exact SDK that built the compiler (allowPrerelease + rollForward:disable) so its + # F# SDK targets and the runtime that runs the local fsc.dll line up, with no silent fallback. + $sdk = (Get-Content "$(Build.SourcesDirectory)/global.json" | ConvertFrom-Json).sdk.version + @{ sdk = @{ version = $sdk; allowPrerelease = $true; rollForward = "disable" } } | ConvertTo-Json | Set-Content "global.json" + Write-Host "Pinned test repo to SDK $sdk" + Get-Content "global.json" + displayName: Pin global.json to compiler SDK for ${{ item.displayName }} - task: UseDotNet@2 displayName: Install .NET SDK 8.0.x for ${{ item.displayName }} @@ -145,7 +124,7 @@ jobs: # into the regression test's .dotnet so fsc.dll can find the runtime. # Tries default feed first, then ci.dot.net/public (same fallback as eng/common). - pwsh: | - $v = (Get-Content "$(Build.SourcesDirectory)/global.json" | ConvertFrom-Json).tools.dotnet + $v = (Get-Content "$(Build.SourcesDirectory)/global.json" | ConvertFrom-Json).sdk.version $d = "$(Pipeline.Workspace)/TestRepo/.dotnet" $u = "https://builds.dotnet.microsoft.com/dotnet/scripts/v1" if ($IsWindows) { @@ -161,19 +140,6 @@ jobs: bash "$d/dotnet-install.sh" --version $v --install-dir $d --skip-non-versioned-files --azure-feed "https://ci.dot.net/public" } displayName: Install compiler SDK for ${{ item.displayName }} - continueOnError: true - - - pwsh: | - Set-Location $(Pipeline.Workspace)/TestRepo - - Write-Host "Running PrepareRepoForRegressionTesting.fsx..." - dotnet fsi $(Build.SourcesDirectory)/eng/scripts/PrepareRepoForRegressionTesting.fsx "$(Pipeline.Workspace)/Props/UseLocalCompiler.Directory.Build.props" - - if ($LASTEXITCODE -ne 0) { - Write-Host "Failed to prepare repository for regression testing" - exit 1 - } - displayName: Setup local compiler configuration for ${{ item.displayName }} - pwsh: | Set-Location $(Pipeline.Workspace)/TestRepo @@ -187,17 +153,17 @@ jobs: Write-Host "" Write-Host "F# Compiler artifacts available:" $productTfm = (dotnet msbuild "$(Pipeline.Workspace)/Props/TargetFrameworks.props" --getProperty:FSharpNetCoreProductTargetFramework).Trim() - Get-ChildItem "$(Pipeline.Workspace)/FSharpCompiler/bin/fsc/Release/$productTfm" -Name -ErrorAction SilentlyContinue + Get-ChildItem "$(Pipeline.Workspace)/FSharpCompiler/artifacts/bin/fsc/Release/$productTfm" -Name -ErrorAction SilentlyContinue Write-Host "" Write-Host "F# Core available:" - if (Test-Path "$(Pipeline.Workspace)/FSharpCompiler/bin/FSharp.Core/Release/netstandard2.0/FSharp.Core.dll") { + if (Test-Path "$(Pipeline.Workspace)/FSharpCompiler/artifacts/bin/FSharp.Core/Release/netstandard2.0/FSharp.Core.dll") { Write-Host "FSharp.Core.dll found" } else { Write-Host "FSharp.Core.dll not found" } Write-Host "" - Write-Host "Directory.Build.props content:" - Get-Content "Directory.Build.props" + Write-Host "Directory.Build.props content (none if injected via CustomAfterDirectoryBuildProps):" + Get-Content "Directory.Build.props" -ErrorAction SilentlyContinue Write-Host "" Write-Host "===========================================" displayName: Report build environment for ${{ item.displayName }} @@ -216,14 +182,6 @@ jobs: - pwsh: | Set-Location $(Pipeline.Workspace)/TestRepo - Write-Host "============================================" - Write-Host "Starting build for ${{ item.displayName }}" - Write-Host "Repository: ${{ item.repo }}" - Write-Host "Commit: ${{ item.commit }}" - Write-Host "Build Script: ${{ item.buildScript }}" - Write-Host "============================================" - Write-Host "" - $errorLogPath = "$(Pipeline.Workspace)/build-errors.log" $fullLogPath = "$(Pipeline.Workspace)/build-full.log" @@ -242,62 +200,24 @@ jobs: } } - function Run-Command { - param([string]$cmd) - if ($cmd -like "dotnet*") { - Write-Host "Executing built-in command: $cmd" - if ($IsWindows) { - cmd /c $cmd 2>&1 | Tee-Object -FilePath $fullLogPath -Append | ForEach-Object { - Process-BuildOutput $_ - } - } else { - # Escape semicolons for bash -c to prevent them being treated as command separators - $escapedCmd = $cmd -replace ';', '\;' - bash -c "$escapedCmd" 2>&1 | Tee-Object -FilePath $fullLogPath -Append | ForEach-Object { - Process-BuildOutput $_ - } - } - } elseif ($IsWindows) { - Write-Host "Executing file-based script: $cmd" - cmd /c ".\$cmd" 2>&1 | Tee-Object -FilePath $fullLogPath -Append | ForEach-Object { - Process-BuildOutput $_ - } - } else { - Write-Host "Executing file-based script: $cmd" - $scriptFile = ($cmd -split ' ', 2)[0] - chmod +x "$scriptFile" - bash -c "./$cmd" 2>&1 | Tee-Object -FilePath $fullLogPath -Append | ForEach-Object { - Process-BuildOutput $_ - } - } - return $LASTEXITCODE - } + # Build logic lives in the fsx so a failure reproduces with the same one command, no pipeline required. + $fsxArgs = @( + "$(Build.SourcesDirectory)/eng/scripts/BuildWithLocalFSharp.fsx", + '--compiler-path', "$(Pipeline.Workspace)/FSharpCompiler", + '--props', "$(Pipeline.Workspace)/Props/UseLocalCompiler.Directory.Build.props", + '--core-packages-dir', "$(Pipeline.Workspace)/Props/library-packs", + '--nuget-packages', "$(Pipeline.Workspace)/.nuget-packages", + '--build-script', '${{ item.buildScript }}' + ) + if ('${{ item.expectLocalCore }}' -eq 'True') { $fsxArgs += '--verify' } - # Support ';;' separator for multiple commands - $commands = ('${{ item.buildScript }}' -split ';;') | ForEach-Object { $_.Trim() } | Where-Object { $_ } - - foreach ($cmd in $commands) { - $exitCode = Run-Command $cmd - if ($exitCode -ne 0) { - Write-Host "" - Write-Host "============================================" - Write-Host "Command failed: $cmd" - Write-Host "Exit code: $exitCode" - Write-Host "============================================" - exit $exitCode - } + dotnet fsi @fsxArgs 2>&1 | Tee-Object -FilePath $fullLogPath -Append | ForEach-Object { Process-BuildOutput $_ } + $code = $LASTEXITCODE + if ($code -ne 0) { + Write-Host "##[error]Build failed for ${{ item.displayName }} (exit code $code)" + exit $code } - - Write-Host "" - Write-Host "============================================" - Write-Host "Build completed for ${{ item.displayName }}" - Write-Host "Exit code: 0" - Write-Host "============================================" displayName: Build ${{ item.displayName }} with local F# compiler - env: - LocalFSharpCompilerPath: $(Pipeline.Workspace)/FSharpCompiler - LoadLocalFSharpBuild: 'True' - LocalFSharpCompilerConfiguration: Release timeoutInMinutes: 120 - pwsh: | @@ -417,27 +337,14 @@ jobs: } Write-Host "" - Write-Host "##[section]LOCAL REPRODUCTION STEPS (from fsharp repo root):" - Write-Host "# 1. Build the F# compiler" - Write-Host "./build.sh -c Release" - Write-Host "" - Write-Host "# 2. Clone and checkout the failing library" - Write-Host "cd .." + $verifyFlag = if ('${{ item.expectLocalCore }}' -eq 'True') { ' --verify' } else { '' } + Write-Host "##[section]LOCAL REPRODUCTION (any OS; FSHARP_REPO = your dotnet/fsharp checkout built with build.sh/Build.cmd -c Release -pack):" Write-Host "git clone --recursive https://github.com/${{ item.repo }}.git TestRepo" - Write-Host "cd TestRepo" - Write-Host "git checkout ${{ item.commit }}" - Write-Host "git submodule update --init --recursive" - Write-Host "rm -f global.json" - Write-Host "" - Write-Host "# 3. Prepare the repo for local compiler" - Write-Host "dotnet fsi ../fsharp/eng/scripts/PrepareRepoForRegressionTesting.fsx `"../fsharp/UseLocalCompiler.Directory.Build.props`"" - Write-Host "" - Write-Host "# 4. Build with local compiler" - Write-Host "export LocalFSharpCompilerPath=`$PWD/../fsharp" - Write-Host "export LoadLocalFSharpBuild=True" - Write-Host "export LocalFSharpCompilerConfiguration=Release" - Write-Host "${{ item.buildScript }}" - + Write-Host "cd TestRepo; git checkout ${{ item.commit }}; git submodule update --init --recursive" + Write-Host 'BUILD_COMMAND: ${{ item.buildScript }}' + Write-Host "dotnet fsi FSHARP_REPO/eng/scripts/BuildWithLocalFSharp.fsx$verifyFlag --build-script ''" + Write-Host "# If TestRepo/global.json pins a different SDK, align sdk.version to your compiler SDK (rollForward: disable); add --nuget-packages to isolate restore." + Write-Host "##vso[task.logissue type=error;sourcepath=azure-pipelines-PR.yml]Regression test failed: ${{ item.displayName }}" } Write-Host "============================================"