diff --git a/.gitignore b/.gitignore index 2969458c35..ce7cfda164 100644 --- a/.gitignore +++ b/.gitignore @@ -227,3 +227,5 @@ Build/Agent/comment-hygiene-report.json # Per-developer preferences for the jira-issue skill .claude/.jira-issue-prefs.json +# Local NuGet feed for packs made by build.ps1 -LocalLibraries +.localfeed/ diff --git a/Build/Agent/powershell-compat.ps1 b/Build/Agent/powershell-compat.ps1 index dac023085e..159aa20cfc 100644 --- a/Build/Agent/powershell-compat.ps1 +++ b/Build/Agent/powershell-compat.ps1 @@ -48,11 +48,14 @@ $ErrorActionPreference = 'Stop' Import-Module (Join-Path $PSScriptRoot 'CommentHygiene.psm1') -Force $repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '../..')).Path -# Every PowerShell file under Build/Agent, so a new script is covered the day it -# lands rather than when someone remembers to name it here. -$targetFiles = @(Get-ChildItem -LiteralPath $PSScriptRoot -Recurse -File | +# Every PowerShell file under Build, not just Build/Agent: build.ps1 loads +# modules from there under Windows PowerShell 5.1 in CI. +$scanRoots = @($PSScriptRoot, (Join-Path $repoRoot 'Build')) +$targetFiles = @($scanRoots | ForEach-Object { + Get-ChildItem -LiteralPath $_ -Recurse -File -ErrorAction SilentlyContinue + } | Where-Object { $_.Extension -eq '.ps1' -or $_.Extension -eq '.psm1' } | - ForEach-Object { $_.FullName } | Sort-Object) + ForEach-Object { $_.FullName } | Sort-Object -Unique) $violations = New-Object System.Collections.ArrayList diff --git a/Build/LocalLibraries.Tests.ps1 b/Build/LocalLibraries.Tests.ps1 new file mode 100644 index 0000000000..64e2135679 --- /dev/null +++ b/Build/LocalLibraries.Tests.ps1 @@ -0,0 +1,124 @@ +<# +.SYNOPSIS + Covers the local-library version stamp that LT-22728 depends on. + +.DESCRIPTION + Run by test.ps1 -LocalLibraryTests, and directly. The property under test is + that a locally packed library can never produce the version string of a + published package, because NuGet keys an extracted package on (id, version). +#> + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$failures = New-Object System.Collections.ArrayList +$tempRoot = Join-Path ([System.IO.Path]::GetTempPath()) ( + 'FieldWorksLocalLibrariesTests_' + [System.Guid]::NewGuid().ToString('N')) + +function Assert-True { + param([bool]$Condition, [string]$Message) + if (-not $Condition) { + [void]$script:failures.Add("FAIL: $Message") + } +} + +function New-GitCheckout { + param([string]$Path) + New-Item -ItemType Directory -Path $Path -Force | Out-Null + & git -C $Path init --quiet + & git -C $Path config user.email 'test@example.com' + & git -C $Path config user.name 'Test' + Set-Content -LiteralPath (Join-Path $Path 'file.txt') -Value 'one' + & git -C $Path add -A + & git -C $Path commit --quiet -m 'initial' +} + +try { + Import-Module (Join-Path $PSScriptRoot 'LocalLibraries.psm1') -Force + + # Get-FieldWorksLocalFeedPath: the default stays inside the working tree, so + # two worktrees cannot feed each other packages. + $savedFeed = $env:LOCAL_NUGET_REPO + try { + $env:LOCAL_NUGET_REPO = $null + $fakeRoot = 'C:' + [System.IO.Path]::DirectorySeparatorChar + 'repo' + $expected = Join-Path $fakeRoot '.localfeed' + Assert-True ((Get-FieldWorksLocalFeedPath -RepositoryRoot $fakeRoot) -eq $expected) ` + 'the feed defaults into the working tree' + $override = 'D:' + [System.IO.Path]::DirectorySeparatorChar + 'myfeed' + $env:LOCAL_NUGET_REPO = $override + Assert-True ((Get-FieldWorksLocalFeedPath -RepositoryRoot $fakeRoot) -eq $override) ` + 'an existing LOCAL_NUGET_REPO still wins' + } + finally { + $env:LOCAL_NUGET_REPO = $savedFeed + } + + # ConvertTo-FieldWorksVersionLabel: a branch name has to survive as a legal + # NuGet pre-release label. + Assert-True ((ConvertTo-FieldWorksVersionLabel -BranchName 'feature/LT-22728') -eq + 'feature-lt-22728') 'a slash becomes a dash and the label lowercases' + Assert-True ((ConvertTo-FieldWorksVersionLabel -BranchName 'a//b') -eq 'a-b') ` + 'runs of separators collapse to one dash' + Assert-True ((ConvertTo-FieldWorksVersionLabel -BranchName '///') -eq 'detached') ` + 'a name with no usable characters falls back to detached' + Assert-True ((ConvertTo-FieldWorksVersionLabel -BranchName ('x' * 40)).Length -le 24) ` + 'a long branch name is truncated' + $truncated = ConvertTo-FieldWorksVersionLabel -BranchName ('ab/' * 20) + Assert-True (-not $truncated.EndsWith('-')) 'truncation never leaves a trailing dash' + + # Get-FieldWorksLocalPackVersion: the published core version must never be + # produced on its own, which is the whole point of LT-22728. + $clean = [pscustomobject]@{ Label = 'main'; ShortSha = 'abc1234'; IsDirty = $false } + $dirty = [pscustomobject]@{ Label = 'main'; ShortSha = 'abc1234'; IsDirty = $true } + Assert-True ((Get-FieldWorksLocalPackVersion -CoreVersion '3.9.2' -SourceState $clean) -eq + '3.9.2-main.abc1234') 'a clean checkout is stamped with its commit' + Assert-True ((Get-FieldWorksLocalPackVersion -CoreVersion '3.9.2' -SourceState $dirty) -eq + '3.9.2-main.dirty') 'uncommitted changes are stamped dirty, not with a commit' + Assert-True ((Get-FieldWorksLocalPackVersion -CoreVersion '3.9.2' -SourceState $clean) -ne + '3.9.2') 'the stamp is never the bare published version' + Assert-True ((Get-FieldWorksLocalPackVersion -CoreVersion '3.9.2-beta' -SourceState $clean) -eq + '3.9.2-main.abc1234') 'an existing pre-release suffix is replaced, not appended' + + # Get-FieldWorksLibrarySourceState: read from a real checkout. + $checkout = Join-Path $tempRoot 'lib' + New-GitCheckout -Path $checkout + $state = Get-FieldWorksLibrarySourceState -SourceDirectory $checkout + Assert-True (-not $state.IsDirty) 'a freshly committed checkout is not dirty' + Assert-True ($state.ShortSha.Length -ge 7) 'the short sha is recorded' + Assert-True ($state.DirtyPaths.Count -eq 0) 'a clean checkout lists no dirty paths' + + Set-Content -LiteralPath (Join-Path $checkout 'file.txt') -Value 'two' + $dirtyState = Get-FieldWorksLibrarySourceState -SourceDirectory $checkout + Assert-True ($dirtyState.IsDirty) 'an edited file makes the checkout dirty' + Assert-True ($dirtyState.DirtyPaths -contains 'file.txt') 'the dirty path is named' + $dirtyStamp = Get-FieldWorksLocalPackVersion -CoreVersion '1.0.0' -SourceState $dirtyState + Assert-True ($dirtyStamp -like '*.dirty') 'a dirty checkout stamps dirty, not a commit' + + $notARepo = Join-Path $tempRoot 'plain' + New-Item -ItemType Directory -Path $notARepo -Force | Out-Null + $threw = $false + try { Get-FieldWorksLibrarySourceState -SourceDirectory $notARepo | Out-Null } + catch { $threw = $true } + Assert-True $threw 'a directory that is not a checkout fails rather than guessing' + + # Get-FieldWorksLibraryCoreVersion: without a VersionProject the pinned + # version supplies the core, and its pre-release suffix is dropped. + Assert-True ((Get-FieldWorksLibraryCoreVersion -SourceDirectory $checkout ` + -LibraryEntry @{} -FallbackVersion '3.9.2-old.1234567') -eq '3.9.2') ` + 'the fallback core drops any existing pre-release suffix' +} +finally { + if (Test-Path -LiteralPath $tempRoot) { + Remove-Item -LiteralPath $tempRoot -Recurse -Force -ErrorAction SilentlyContinue + } +} + +if ($failures.Count -gt 0) { + Write-Host "Local library tests failed:" -ForegroundColor Red + $failures | ForEach-Object { Write-Host " $_" -ForegroundColor Red } + exit 1 +} + +Write-Host "[PASS] Local library version tests" -ForegroundColor Green +exit 0 diff --git a/Build/LocalLibraries.psm1 b/Build/LocalLibraries.psm1 new file mode 100644 index 0000000000..6bc7cb8e84 --- /dev/null +++ b/Build/LocalLibraries.psm1 @@ -0,0 +1,102 @@ +<# +.SYNOPSIS + Derives the version string a locally packed SIL library is stamped with. + +.DESCRIPTION + A local pack must never produce the version string a published package + already uses. NuGet keys an extracted package on (id, version) and, once + unpacked into the repository's packages folder, never consults the .nupkg + again -- so a local build sharing the published version keeps satisfying + restores after its .nupkg is deleted. Stamping the pack with the source + state makes that collision impossible. See LT-22728. +#> + +Set-StrictMode -Version Latest + +function Get-FieldWorksLocalFeedPath { + param([string]$RepositoryRoot) + + # LOCAL_NUGET_REPO still wins, so an existing setup keeps working. The + # default lives in the working tree: no machine-level state, and two + # worktrees cannot feed each other packages. + if ($env:LOCAL_NUGET_REPO) { + return $env:LOCAL_NUGET_REPO + } + return (Join-Path $RepositoryRoot '.localfeed') +} + +function ConvertTo-FieldWorksVersionLabel { + param([string]$BranchName) + $label = ($BranchName -replace '[^0-9A-Za-z-]', '-').Trim('-') + $label = $label -replace '-{2,}', '-' + if ([string]::IsNullOrWhiteSpace($label)) { + return 'detached' + } + if ($label.Length -gt 24) { + $label = $label.Substring(0, 24).Trim('-') + } + return $label.ToLowerInvariant() +} + +function Get-FieldWorksLibrarySourceState { + param([string]$SourceDirectory) + + $branch = (& git -C $SourceDirectory rev-parse --abbrev-ref HEAD 2>$null) + if ($LASTEXITCODE -ne 0) { + throw "'$SourceDirectory' is not a git checkout; cannot derive a local version." + } + $branch = "$branch".Trim() + if ($branch -eq 'HEAD') { + $branch = 'detached' + } + + $shortSha = "$(& git -C $SourceDirectory rev-parse --short=7 HEAD 2>$null)".Trim() + $status = @(& git -C $SourceDirectory status --porcelain --untracked-files=normal 2>$null) + + return [pscustomobject]@{ + Branch = $branch + Label = ConvertTo-FieldWorksVersionLabel -BranchName $branch + ShortSha = $shortSha + IsDirty = $status.Count -gt 0 + DirtyPaths = @($status | ForEach-Object { ($_ -replace '^.{2,3}', '').Trim() }) + } +} + +function Get-FieldWorksLibraryCoreVersion { + param([string]$SourceDirectory, [hashtable]$LibraryEntry, [string]$FallbackVersion) + + $fallbackCore = ($FallbackVersion -split '-', 2)[0] + if (-not $LibraryEntry.Contains('VersionProject')) { + return $fallbackCore + } + $project = Join-Path $SourceDirectory $LibraryEntry.VersionProject + if (-not (Test-Path -LiteralPath $project)) { + Write-Warning "Version project '$project' not found; using $fallbackCore." + return $fallbackCore + } + + # -restore first: GetVersion comes from the GitVersion package, which a + # checkout that has never been built does not have yet. + $probed = & dotnet msbuild $project -restore -t:GetVersion ` + -getProperty:GitVersion_MajorMinorPatch -v:q -nologo 2>$null + $probed = @($probed | Where-Object { $_ -match '^\d+\.\d+\.\d+$' }) + if ($LASTEXITCODE -ne 0 -or $probed.Count -eq 0) { + Write-Warning "Could not read a GitVersion version from '$project'; using $fallbackCore." + return $fallbackCore + } + return $probed[-1].Trim() +} + +function Get-FieldWorksLocalPackVersion { + param([string]$CoreVersion, [pscustomobject]$SourceState) + + $core = ($CoreVersion -split '-', 2)[0] + if ($SourceState.IsDirty) { + return "$core-$($SourceState.Label).dirty" + } + return "$core-$($SourceState.Label).$($SourceState.ShortSha)" +} + +Export-ModuleMember -Function Get-FieldWorksLocalFeedPath, ConvertTo-FieldWorksVersionLabel, + Get-FieldWorksLibrarySourceState, Get-FieldWorksLibraryCoreVersion, + Get-FieldWorksLocalPackVersion diff --git a/Build/Manage-LocalLibraries.ps1 b/Build/Manage-LocalLibraries.ps1 index 2f5e7eb1e5..cf8ecf85f6 100644 --- a/Build/Manage-LocalLibraries.ps1 +++ b/Build/Manage-LocalLibraries.ps1 @@ -107,6 +107,8 @@ $ErrorActionPreference = "Stop" # Library-specific configuration # --------------------------------------------------------------------------- +Import-Module (Join-Path $PSScriptRoot 'LocalLibraries.psm1') -Force + $LibraryConfig = @{ palaso = @{ VersionProperty = 'SilLibPalasoVersion' @@ -117,22 +119,29 @@ $LibraryConfig = @{ 'sil.media', 'sil.scripture', 'sil.testutilities' ) EnvVar = 'LIBPALASO_PATH' + VersionProject = 'SIL.Core/SIL.Core.csproj' } lcm = @{ VersionProperty = 'SilLcmVersion' PdbRelativeDir = 'artifacts/Debug/net462' CachePrefixes = @('sil.lcmodel') EnvVar = 'LIBLCM_PATH' + VersionProject = 'src/SIL.LCModel/SIL.LCModel.csproj' } chorus = @{ VersionProperty = 'SilChorusVersion' PdbRelativeDir = 'output/Debug/net462' CachePrefixes = @('sil.chorus') EnvVar = 'LIBCHORUS_PATH' + VersionProject = 'src/Chorus/Chorus.csproj' } machine = @{ VersionProperty = 'SilMachineVersion' - PdbRelativeDir = 'bin/Debug/netstandard2.0' + # Machine writes per-project, not at the repository root. + PdbRelativeDir = @( + 'src/SIL.Machine/bin/Debug/netstandard2.0', + 'src/SIL.Machine.Morphology.HermitCrab/bin/Debug/netstandard2.0' + ) CachePrefixes = @('sil.machine') EnvVar = 'SILMACHINE_PATH' # Pack only the projects FieldWorks uses (avoids native CMake deps) @@ -143,9 +152,11 @@ $LibraryConfig = @{ } l10nsharp = @{ VersionProperty = 'L10NSharpVersion' - PdbRelativeDir = 'output/Debug/net462' + # l10nsharp builds net461/net48/net8.0, never net462; FieldWorks is net48. + PdbRelativeDir = 'output/Debug/net48' CachePrefixes = @('l10nsharp') EnvVar = 'L10NSHARP_PATH' + VersionProject = 'src/L10NSharp/L10NSharp.csproj' } } @@ -183,25 +194,61 @@ function Get-VersionNode { # Helper: update SilVersions.props and clear stale cached packages # --------------------------------------------------------------------------- -function Update-VersionAndClearCache { - param([string]$LibName, [string]$NewVersion) - $cfg = $LibraryConfig[$LibName] - $node = Get-VersionNode $LibName - $node.InnerText = $NewVersion - - # Save with XmlWriter to preserve tab indentation (XmlDocument.Save() converts tabs to spaces) +function Save-VersionProps { + # XmlWriter, not XmlDocument.Save: Save() turns the file's tabs into spaces. $writerSettings = New-Object System.Xml.XmlWriterSettings $writerSettings.Indent = $true $writerSettings.IndentChars = "`t" $writerSettings.NewLineChars = "`r`n" - $writerSettings.Encoding = New-Object System.Text.UTF8Encoding($false) # UTF-8 without BOM - $writerSettings.OmitXmlDeclaration = -not $versionProps.FirstChild.NodeType.Equals([System.Xml.XmlNodeType]::XmlDeclaration) + $writerSettings.Encoding = New-Object System.Text.UTF8Encoding($false) + $writerSettings.OmitXmlDeclaration = -not $versionProps.FirstChild.NodeType.Equals( + [System.Xml.XmlNodeType]::XmlDeclaration) $writer = [System.Xml.XmlWriter]::Create($versionPropsPath, $writerSettings) try { $versionProps.WriteTo($writer) } finally { $writer.Close() } + # XmlWriter does not emit a final newline, and this file is tracked: without + # one every pack shows a spurious \ No newline at end of file. + $text = [System.IO.File]::ReadAllText($versionPropsPath) + if (-not $text.EndsWith("`n")) { + [System.IO.File]::AppendAllText($versionPropsPath, "`r`n") + } +} + +function Set-LocalFeedSource { + param([string]$LocalRepository) + + $group = $versionProps.SelectSingleNode( + "//PropertyGroup[@Label='SIL Ecosystem Versions']") + $name = 'RestoreAdditionalProjectSources' + $node = $group.SelectSingleNode($name) + if (-not $node) { + $node = $versionProps.CreateElement($name) + # Indent it: this file is tracked, so the diff a developer sees must be tidy. + [void]$group.AppendChild($versionProps.CreateWhitespace("`r`n`t`t")) + [void]$group.AppendChild($node) + [void]$group.AppendChild($versionProps.CreateWhitespace("`r`n`t")) + } + # Append, so a feed set for one library survives packing the next. + $existing = @($node.InnerText -split ';' | Where-Object { $_ }) + if ($existing -notcontains $LocalRepository) { + $existing += $LocalRepository + } + $node.InnerText = ($existing -join ';') + Save-VersionProps + Write-Host "Pointed restore at the local feed: $LocalRepository" ` + -ForegroundColor Yellow +} + +function Update-VersionAndClearCache { + param([string]$LibName, [string]$NewVersion) + $cfg = $LibraryConfig[$LibName] + $node = Get-VersionNode $LibName + $node.InnerText = $NewVersion + + Save-VersionProps Write-Host "Updated SilVersions.props ($($cfg.VersionProperty) = $NewVersion)" -ForegroundColor Yellow @@ -256,9 +303,27 @@ function Invoke-PackLibrary { # Record timestamp before pack so we can find newly-produced packages $packStart = Get-Date + # LT-22728: stamp the pack with the source state. Sharing the published + # version lets an extracted copy keep satisfying restores after its + # .nupkg is gone, because NuGet keys on (id, version) alone. + $sourceState = Get-FieldWorksLibrarySourceState -SourceDirectory $SourceDir + $coreVersion = Get-FieldWorksLibraryCoreVersion -SourceDirectory $SourceDir ` + -LibraryEntry $cfg -FallbackVersion $node.InnerText.Trim() + $packVersion = Get-FieldWorksLocalPackVersion -CoreVersion $coreVersion ` + -SourceState $sourceState + Write-Host " Packing as: $packVersion" -ForegroundColor Cyan + if ($sourceState.IsDirty) { + Write-Host " Source has uncommitted changes; the stamp says dirty." ` + -ForegroundColor Yellow + } + Write-Host "Running dotnet pack..." -ForegroundColor Cyan $commonPackArgs = @( '-c', 'Debug' + "-p:Version=$packVersion" + # GitVersion.MsBuild assigns Version inside a target, which outranks a + # command-line property, so it must be off for the stamp to hold. + '-p:DisableGitVersionTask=true' "-p:IncludeSymbols=true" "-p:SymbolPackageFormat=snupkg" '--output', $LocalRepo @@ -300,21 +365,15 @@ function Invoke-PackLibrary { Write-Host "New packages found:" -ForegroundColor Gray $newPackages | ForEach-Object { Write-Host " $($_.Name)" -ForegroundColor Gray } + # The stamp is the contract. If GitVersion still won, the produced package + # carries another version and the collision this guards against is back. $detectedVersions = @($newPackages | ForEach-Object { Get-PackageVersion $_.Name } | Where-Object { $_ } | Sort-Object -Unique) - - Write-Host "Detected version(s): $($detectedVersions -join ', ')" -ForegroundColor Gray - - if ($detectedVersions.Count -eq 0) { - throw "Could not parse version from produced packages: $($newPackages.Name -join ', ')" + $unstamped = @($detectedVersions | Where-Object { $_ -ne $packVersion }) + if ($unstamped.Count -gt 0) { + throw ("Packed $LibName as '$($unstamped -join ", ")' rather than " + + "'$packVersion'. The -p:Version stamp was overridden.") } - if ($detectedVersions.Count -gt 1) { - Write-Host "WARNING: Multiple versions detected in produced packages:" -ForegroundColor Red - $detectedVersions | ForEach-Object { Write-Host " $_" -ForegroundColor Red } - throw "Expected all packages to share one version. Clean $LocalRepo and retry." - } - - $packVersion = $detectedVersions[0] Write-Host "" Write-Host "Pack complete ($($newPackages.Count) package(s), version $packVersion)." -ForegroundColor Green @@ -323,9 +382,10 @@ function Invoke-PackLibrary { Write-Host "To revert: git checkout Build/SilVersions.props" -ForegroundColor Yellow # Copy PDB files to Output/Debug/ and Downloads/ - $pdbSourceDir = Join-Path $SourceDir $cfg.PdbRelativeDir + $pdbSourceDirs = @($cfg.PdbRelativeDir | ForEach-Object { Join-Path $SourceDir $_ }) + $foundPdbDirs = @($pdbSourceDirs | Where-Object { Test-Path $_ }) - if (Test-Path $pdbSourceDir) { + if ($foundPdbDirs.Count -gt 0) { $outputDebugDir = Join-Path $repoRoot "Output/Debug" $downloadsDir = Join-Path $repoRoot "Downloads" @@ -335,18 +395,22 @@ function Invoke-PackLibrary { } } - $pdbFiles = @(Get-ChildItem -Path $pdbSourceDir -Filter "*.pdb" -File) + $pdbFiles = @($foundPdbDirs | ForEach-Object { + Get-ChildItem -Path $_ -Filter "*.pdb" -File + }) if ($pdbFiles.Count -gt 0) { Write-Host "Copying $($pdbFiles.Count) PDB file(s) to Output/Debug/ and Downloads/..." -ForegroundColor Cyan $pdbFiles | Copy-Item -Destination $outputDebugDir -Force $pdbFiles | Copy-Item -Destination $downloadsDir -Force } else { - Write-Host "No PDB files found in $pdbSourceDir" -ForegroundColor Yellow + Write-Host "No PDB files found in: $($foundPdbDirs -join ', ')" ` + -ForegroundColor Yellow } } else { - Write-Host "PDB source directory not found: $pdbSourceDir (PDBs will only be in .snupkg)" -ForegroundColor Yellow + Write-Host ("PDB source directory not found, so PDBs are only in the .snupkg. " + + "Looked in: $($pdbSourceDirs -join ', ')") -ForegroundColor Yellow } Write-Host "" @@ -398,25 +462,15 @@ if ($toPack.Count -gt 0) { Write-Host "WARNING: -Version is ignored in pack mode (version is detected from produced packages)." -ForegroundColor Yellow } - $localRepo = $env:LOCAL_NUGET_REPO - if (-not $localRepo) { - throw "The LOCAL_NUGET_REPO environment variable is not set. Set it to a folder path (e.g. C:\localnugetpackages)." - } + $localRepo = Get-FieldWorksLocalFeedPath -RepositoryRoot $repoRoot if (-not (Test-Path $localRepo)) { - Write-Host "Creating local NuGet repo folder: $localRepo" -ForegroundColor Yellow + Write-Host "Creating local NuGet feed folder: $localRepo" -ForegroundColor Yellow New-Item -Path $localRepo -ItemType Directory -Force | Out-Null } - # Ensure local NuGet source is registered (user-level config) - $sourceList = & dotnet nuget list source 2>&1 - $normalizedRepo = [System.IO.Path]::GetFullPath($localRepo).TrimEnd('\', '/') - $alreadyRegistered = $sourceList | Where-Object { - $_.Trim() -replace '[\\/]$', '' -ieq $normalizedRepo - } - if (-not $alreadyRegistered) { - & dotnet nuget add source $localRepo --name local 2>&1 | Out-Null - Write-Host "Added local NuGet source: $localRepo" -ForegroundColor Yellow - } + # The feed path rides in SilVersions.props, which PackageRestore.targets + # imports, so a nested restore in its own process sees it. No machine-level + # NuGet source: one outlives its checkout. Write-Host "" Write-Host "Libraries to pack: $($toPack.Keys -join ', ')" -ForegroundColor Cyan @@ -425,6 +479,8 @@ if ($toPack.Count -gt 0) { Invoke-PackLibrary -LibName $lib -SourceDir $toPack[$lib] -LocalRepo $localRepo } + Set-LocalFeedSource -LocalRepository $localRepo + Write-Host "" Write-Host "========================================" -ForegroundColor Green Write-Host "[OK] All libraries packed. Run .\build.ps1 to build." -ForegroundColor Green diff --git a/Docs/architecture/dependencies.md b/Docs/architecture/dependencies.md index 140ae06601..72e94e48ae 100644 --- a/Docs/architecture/dependencies.md +++ b/Docs/architecture/dependencies.md @@ -34,14 +34,13 @@ By default, dependencies are downloaded as NuGet packages during the build. The ## Building and Debugging Dependencies Locally -If you need to debug into or modify a dependency library, use the `Build/Manage-LocalLibraries.ps1` script. It packs a local checkout into a local NuGet feed, detects the produced version, and updates `SilVersions.props` to match. +If you need to debug into or modify a dependency library, use the `Build/Manage-LocalLibraries.ps1` script. It packs a local checkout into `.localfeed` in this working tree and pins the packed version in `SilVersions.props`. That pin is tracked and left dirty on purpose: it records that the tree depends on a library version nobody has released yet, so clearing it is a manual step. `build.ps1 -LocalLibraries ` does the pack and the build in one command. Quick start: ```powershell -$env:LOCAL_NUGET_REPO = "C:\localnugetpackages" -.\Build\Manage-LocalLibraries.ps1 -Palaso -PalasoPath C:\Repos\libpalaso -.\build.ps1 +$env:LIBPALASO_PATH = "C:\Repos\libpalaso" +.\build.ps1 -LocalLibraries palaso ``` For the full workflow (setup, pack, build, debug, revert), see **[Local Library Debugging](local-library-debugging.md)**. diff --git a/Docs/architecture/local-library-debugging.md b/Docs/architecture/local-library-debugging.md index 02b2fba90d..f0dddd0b42 100644 --- a/Docs/architecture/local-library-debugging.md +++ b/Docs/architecture/local-library-debugging.md @@ -6,7 +6,7 @@ This document describes how to debug locally-modified versions of **liblcm**, ** The workflow uses a single PowerShell script (`Build/Manage-LocalLibraries.ps1`) that: -1. Adds a local NuGet source to `nuget.config` (pointing to your `LOCAL_NUGET_REPO` folder). +1. Packs into `.localfeed` in this working tree and records that feed path in `SilVersions.props`. 2. Runs `dotnet pack` in Debug configuration with symbols, letting the library use its own version. 3. Detects the version from the produced packages. 4. Updates `SilVersions.props` so FieldWorks resolves that exact version. @@ -18,35 +18,35 @@ This approach works identically for all three libraries. ## Setup (one-time) -### 1. Create a local NuGet folder - -Pick any folder, for example: - -``` -C:\localnugetpackages -``` - -### 2. Set the `LOCAL_NUGET_REPO` environment variable +### 1. Clone the library you need ```powershell -# Current session -$env:LOCAL_NUGET_REPO = "C:\localnugetpackages" - -# Persistent (user-level) -[System.Environment]::SetEnvironmentVariable("LOCAL_NUGET_REPO", "C:\localnugetpackages", "User") +git clone https://github.com/sillsdev/liblcm.git +git clone https://github.com/sillsdev/libpalaso.git +git clone https://github.com/sillsdev/chorus.git +git clone https://github.com/sillsdev/machine.git ``` -The script automatically registers this folder as a NuGet source in your user-level NuGet config when you pack. The repo's `nuget.config` is not modified. +### 2. Nothing else -### 3. Clone the library you need +Packed packages go to `.localfeed` inside this working tree, which is created on +demand and gitignored. No environment variable and no user-level NuGet source are +needed: `Build/SilVersions.props` carries the feed path, and +`Build/PackageRestore.targets` imports it, so even a nested restore in its own +process finds it. Set `LOCAL_NUGET_REPO` if you would rather share one feed across +checkouts; it still wins. + +## One command ```powershell -git clone https://github.com/sillsdev/liblcm.git -git clone https://github.com/sillsdev/libpalaso.git -git clone https://github.com/sillsdev/chorus.git -git clone https://github.com/sillsdev/machine.git +$env:SILMACHINE_PATH = "C:\Repos\machine" +.uild.ps1 -LocalLibraries machine ``` +That packs the library and then builds. It is a wrapper over the two steps below: +the version is pinned in tracked `Build/SilVersions.props` exactly as when the pack +script is run by hand, and clearing that pin stays a manual step. + ## Pack a local library ```powershell @@ -70,19 +70,40 @@ $env:SILMACHINE_PATH = "C:\Repos\machine" ``` The script: -- Lets the library build with its own version (no version override). +- Stamps the pack with the source state, so the version can never be one a + published package already uses (see Version stamping below). - Detects the produced version and updates `Build/SilVersions.props` to match. - Produces `.snupkg` symbol packages (same format as production). - Copies PDB files to `Output/Debug/` and `Downloads/` for the debugger. - Clears stale packages from the `packages/` cache. +## Version stamping + +A local pack is never given the published version string. It is stamped from the +library checkout, so `3.9.2` packs as, for example: + +``` +3.9.2-my-branch.d3b7643 committed +3.9.2-my-branch.dirty uncommitted changes present +``` + +NuGet keys an extracted package on (id, version) and, once it has unpacked one +into `packages/`, never consults the `.nupkg` again. A local build sharing the +published version therefore kept satisfying restores after its `.nupkg` was +deleted, silently, for as long as the folder survived. A stamped version cannot +collide, so an ordinary build resolves the published package again (LT-22728). + +The stamp also makes the pin in `SilVersions.props` self-describing: a version +with a branch and commit in it is visibly not something anyone can restore from +nuget.org. + ## Build FieldWorks ```powershell .\build.ps1 ``` -The build will print a yellow message listing any local packages detected in `LOCAL_NUGET_REPO`. NuGet restore will use your local packages because `SilVersions.props` was updated to request the exact version produced by the library. +The build prints a yellow message listing any local packages in the feed. NuGet restore will use your local packages because `SilVersions.props` was updated to request the exact version produced by the library. ## Debug @@ -120,6 +141,10 @@ Use `-Version` to set the library back to its upstream version: .\Build\Manage-LocalLibraries.ps1 -Library libpalaso -Version 17.0.0 ``` +When the library change is released, set the pin to the released version rather +than reverting. The dirty `SilVersions.props` is the reminder that FieldWorks is +still depending on something unpublished. + Or revert all libraries at once: ```powershell @@ -128,7 +153,7 @@ Remove-Item -Recurse packages/sil.* .\build.ps1 ``` -To also remove the user-level local source: +If an older setup registered a user-level NuGet source, remove it too: ```powershell dotnet nuget remove source local diff --git a/build.ps1 b/build.ps1 index e400457c76..beb74a89c5 100644 --- a/build.ps1 +++ b/build.ps1 @@ -110,6 +110,13 @@ and copies the resulting DLLs into the output directory, overwriting the NuGet package versions. Use this to test local liblcm fixes without publishing a NuGet package. +.PARAMETER LocalLibraries + Local SIL libraries to repack before building: palaso, lcm, chorus, machine, l10nsharp. + Runs Build/Manage-LocalLibraries.ps1 for each, which pins the packed version in + Build/SilVersions.props. That pin is tracked and is left dirty on purpose: it records + that this tree depends on a library version nobody has released. Clear it yourself, + by setting the released version or with git checkout Build/SilVersions.props. + .PARAMETER LocalLcmPath Path to the local liblcm repository. Defaults to ../liblcm relative to the FieldWorks repo root. Only used when -UseLocalLcm is specified. @@ -198,6 +205,8 @@ param( [switch]$EnableTracing, [switch]$UseLocalLcm, [string]$LocalLcmPath, + [ValidateSet('palaso', 'lcm', 'chorus', 'machine', 'l10nsharp')] + [string[]]$LocalLibraries = @(), [ValidateSet('user', 'agent', 'unknown')] [string]$StartedBy = 'unknown', [switch]$SkipWorktreeLock, @@ -595,6 +604,27 @@ try { & $staleDllScript -OutputDir $outputDir -RepoRoot $PSScriptRoot -Verbose:$VerbosePreference } + Import-Module (Join-Path $PSScriptRoot 'Build/LocalLibraries.psm1') -Force + $localFeed = Get-FieldWorksLocalFeedPath -RepositoryRoot $PSScriptRoot + + # A thin wrapper over Manage-LocalLibraries.ps1: it packs and pins exactly as + # it does when run by hand, so the tracked pin and its revert instruction are + # unchanged. This only saves the second command. + if ($LocalLibraries.Count -gt 0) { + if ($UseLocalLcm -and $LocalLibraries -contains 'lcm') { + throw 'Choose either -LocalLibraries lcm or -UseLocalLcm, not both.' + } + $switchFor = @{ palaso = 'Palaso'; lcm = 'Lcm'; chorus = 'Chorus' + machine = 'Machine'; l10nsharp = 'L10nSharp' } + $managerArgs = @{} + foreach ($localLibrary in $LocalLibraries) { + $managerArgs[$switchFor[$localLibrary]] = $true + } + # That script reports failure by throwing, so $LASTEXITCODE would hold the + # last native command's result rather than its verdict. + & (Join-Path $PSScriptRoot 'Build/Manage-LocalLibraries.ps1') @managerArgs + } + # ============================================================================= # Build Configuration # ============================================================================= @@ -677,16 +707,23 @@ try { Write-Host "Including optional FieldWorks executables" -ForegroundColor Yellow } - # Report local library packages when LOCAL_NUGET_REPO is configured - if ($env:LOCAL_NUGET_REPO -and (Test-Path $env:LOCAL_NUGET_REPO)) { - $localPkgs = Get-ChildItem -Path $env:LOCAL_NUGET_REPO -Filter "SIL.*.nupkg" -File -ErrorAction SilentlyContinue + # Report only what restore will actually resolve. A file in the feed proves + # nothing: the pin and the feed entry in SilVersions.props are what wire it in. + $versionPropsText = '' + $versionPropsFile = Join-Path $PSScriptRoot 'Build/SilVersions.props' + if (Test-Path $versionPropsFile) { + $versionPropsText = Get-Content -LiteralPath $versionPropsFile -Raw + } + # Literal compare: -like would treat a bracket in the path as a wildcard. + if ($versionPropsText.Contains($localFeed)) { + $localPkgs = @(Get-ChildItem -Path $localFeed -Filter "SIL.*.nupkg" -File -ErrorAction SilentlyContinue) if ($localPkgs.Count -gt 0) { Write-Host "" - Write-Host "Local library packages detected in $($env:LOCAL_NUGET_REPO):" -ForegroundColor Yellow + Write-Host "Local library packages wired into this restore from ${localFeed}:" -ForegroundColor Yellow foreach ($pkg in $localPkgs) { Write-Host " $($pkg.Name)" -ForegroundColor Yellow } - Write-Host "These will shadow upstream NuGet packages during restore." -ForegroundColor Yellow + Write-Host "Build/SilVersions.props pins these, so it is dirty until you set the released version." -ForegroundColor Yellow Write-Host "" } } diff --git a/nuget.config b/nuget.config index 3d86798cf0..d578bb3216 100644 --- a/nuget.config +++ b/nuget.config @@ -30,8 +30,9 @@ diff --git a/test.ps1 b/test.ps1 index e68ad633cb..4695d7f2d3 100644 --- a/test.ps1 +++ b/test.ps1 @@ -36,6 +36,11 @@ Allow FieldWorks Abort/Retry/Ignore assertion dialogs during this local test run. Equivalent environment variable: FW_TEST_ALLOW_ASSERT_DIALOGS=1. +.PARAMETER LocalLibraryTests + Run Build/LocalLibraries.Tests.ps1, which covers the local-library version stamp. + Off by default: local libraries are a deliberate, occasional workflow, so the check + does not gate runs that never touch one. + .PARAMETER CommentHygiene Enforce the comment-hygiene check, failing the run on any violation in the lines this branch adds. @@ -114,7 +119,8 @@ param( [ValidateSet('user', 'agent', 'unknown')] [string]$StartedBy = 'unknown', [switch]$CommentHygiene, - [switch]$TokenHygiene + [switch]$TokenHygiene, + [switch]$LocalLibraryTests ) $ErrorActionPreference = 'Stop' @@ -127,6 +133,14 @@ if ($CommentHygiene) { } } +if ($LocalLibraryTests) { + $localLibraryTestPath = Join-Path $PSScriptRoot "Build/LocalLibraries.Tests.ps1" + & $localLibraryTestPath + if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE + } +} + # Token hygiene blocks the run with -TokenHygiene. Without the flag, CI=true or # GITHUB_ACTIONS=true still forces an advisory run that annotates the pull request. An # ordinary developer run is silent.