diff --git a/.github/workflows/BuildModule.yml b/.github/workflows/BuildModule.yml index 528fbde7..6a686dcd 100644 --- a/.github/workflows/BuildModule.yml +++ b/.github/workflows/BuildModule.yml @@ -72,12 +72,33 @@ jobs: key: ${{ runner.os }}-nuget-${{ hashFiles('global.json', 'NuGet.config', '**/packages.lock.json') }} restore-keys: | ${{ runner.os }}-nuget- + + - name: Verify net472 compatibility + shell: pwsh + timeout-minutes: 10 + run: | + dotnet build .\PowerForge\PowerForge.csproj -c Release -f net472 --nologo + dotnet build .\PowerForge.PowerShell\PowerForge.PowerShell.csproj -c Release -f net472 --nologo + dotnet build .\PSPublishModule\PSPublishModule.csproj -c Release -f net472 --nologo + + - name: Run net472 C# smoke tests + shell: pwsh + timeout-minutes: 10 + run: dotnet test .\PowerForge.Net472SmokeTests\PowerForge.Net472SmokeTests.csproj -c Release -f net472 --nologo + - name: Build Module shell: pwsh timeout-minutes: 10 run: .\Build\Build-Module.ps1 - - name: Test with Pester + - name: Test with Windows PowerShell 5.1 + shell: pwsh + timeout-minutes: 10 + run: | + & "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File .\PSPublishModule.Tests.ps1 + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + - name: Test with PowerShell 7 shell: pwsh timeout-minutes: 10 run: .\PSPublishModule.Tests.ps1 diff --git a/Module/Build/Build-Module.ps1 b/Module/Build/Build-Module.ps1 index 1965f178..6d56f2e5 100644 --- a/Module/Build/Build-Module.ps1 +++ b/Module/Build/Build-Module.ps1 @@ -57,9 +57,30 @@ Get-Module -Name 'PSPublishModule' -All -ErrorAction SilentlyContinue | Remove-M $importPath = $null -# The source manifest bootstrap requires Module\Lib to exist. Self-builds generate the -# pipeline config before staged module libraries are present, so prefer the compiled DLL then. -if (Test-Path -LiteralPath $sourceLibRoot) { +# Json-only and no-dotnet-build flows should avoid importing the source manifest from Module\Lib. +# Once a PowerShell session loads those repo DLLs on Windows, later self-build attempts cannot +# refresh them in-place. Import the compiled binary module instead for config generation. +$preferBinaryImport = $JsonOnly -or $NoDotnetBuild + +if ($preferBinaryImport) { + if (-not (Test-Path -LiteralPath $binaryModule)) { + if (Test-Path -LiteralPath $csproj) { + $i = [char]0x2139 # ℹ + Write-Host "$i Building PSPublishModule ($Configuration)" -ForegroundColor DarkGray + $buildOutput = & dotnet build $csproj -c $Configuration --nologo --verbosity quiet 2>&1 + if ($LASTEXITCODE -ne 0) { + $buildOutput | Out-Host + throw "dotnet build failed (exit $LASTEXITCODE)." + } + } + } + + if (Test-Path -LiteralPath $binaryModule) { + $importPath = $binaryModule + } +} elseif (Test-Path -LiteralPath $sourceLibRoot) { + # Keep the source-manifest path for regular repo builds where Module\Lib is intentionally populated. + # Build-ModuleSelf now prefers binary import to avoid in-place refreshes of loaded repo DLLs. $importPath = $sourceManifest } else { if (-not (Test-Path -LiteralPath $binaryModule)) { diff --git a/Module/Build/Build-ModuleSelf.ps1 b/Module/Build/Build-ModuleSelf.ps1 index 967d70ff..5ca8e960 100644 --- a/Module/Build/Build-ModuleSelf.ps1 +++ b/Module/Build/Build-ModuleSelf.ps1 @@ -55,37 +55,6 @@ $moduleProject = Join-Path -Path $repoRoot -ChildPath 'PSPublishModule\PSPublish if (-not (Test-Path -LiteralPath $cliProject)) { throw "PowerForge.Cli project not found: $cliProject" } if (-not (Test-Path -LiteralPath $moduleProject)) { throw "PSPublishModule project not found: $moduleProject" } -function Sync-LocalModuleLib { - param( - [Parameter(Mandatory)][string] $RepoRoot, - [Parameter(Mandatory)][string] $ConfigurationName, - [Parameter(Mandatory)][string] $PrimaryFramework - ) - - $moduleLibRoot = Join-Path -Path $RepoRoot -ChildPath 'Module\Lib' - New-Item -Path $moduleLibRoot -ItemType Directory -Force | Out-Null - - $frameworkMappings = @( - @{ Output = $PrimaryFramework; Folder = 'Core' } - @{ Output = 'net472'; Folder = 'Default' } - ) - - foreach ($mapping in $frameworkMappings) { - $outputPath = Join-Path -Path $RepoRoot -ChildPath ("PSPublishModule\bin\{0}\{1}" -f $ConfigurationName, $mapping.Output) - if (-not (Test-Path -LiteralPath $outputPath)) { - continue - } - - $targetPath = Join-Path -Path $moduleLibRoot -ChildPath $mapping.Folder - if (Test-Path -LiteralPath $targetPath) { - Remove-Item -LiteralPath $targetPath -Recurse -Force -ErrorAction SilentlyContinue - } - - New-Item -Path $targetPath -ItemType Directory -Force | Out-Null - Copy-Item -Path (Join-Path -Path $outputPath -ChildPath '*') -Destination $targetPath -Recurse -Force - } -} - if ($Framework -eq 'auto') { $runtimesText = (dotnet --list-runtimes 2>$null) -join "`n" $Framework = if ($runtimesText -match '(?m)^Microsoft\\.NETCore\\.App\\s+10\\.') { 'net10.0' } else { 'net8.0' } @@ -118,8 +87,6 @@ if (-not $NoBuild) { if ($LASTEXITCODE -ne 0) { $moduleOutput | Out-Host; exit $LASTEXITCODE } Write-Host "$ok Built PSPublishModule ($Framework, $Configuration)" -ForegroundColor Green } - - Sync-LocalModuleLib -RepoRoot $repoRoot -ConfigurationName $Configuration -PrimaryFramework $Framework } $cliDir = Join-Path -Path $repoRoot -ChildPath ("PowerForge.Cli\\bin\\{0}\\{1}" -f $Configuration, $Framework) diff --git a/Module/PSPublishModule.Tests.ps1 b/Module/PSPublishModule.Tests.ps1 index bfb830cd..b6edfabb 100644 --- a/Module/PSPublishModule.Tests.ps1 +++ b/Module/PSPublishModule.Tests.ps1 @@ -1,5 +1,26 @@ $script:SourceRoot = $PSScriptRoot -$script:CopiedSourceLib = $false + +function Test-ModulePayloadUsableForCurrentHost { + param( + [Parameter(Mandatory)][string] $ModuleRoot + ) + + $libRoot = Join-Path $ModuleRoot 'Lib' + if (-not (Test-Path -LiteralPath $libRoot -PathType Container)) { + return $false + } + + $directories = Get-ChildItem -Path $libRoot -Directory -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Name + $hasCore = $directories -contains 'Core' + $hasDefault = $directories -contains 'Default' + $hasStandard = $directories -contains 'Standard' + + if ($PSEdition -eq 'Core') { + return $hasCore -or $hasStandard + } + + return $hasDefault -or $hasStandard +} function Get-ModulePayloadRootForTests { param( @@ -11,49 +32,61 @@ function Get-ModulePayloadRootForTests { throw "Path $SourceRoot doesn't contain a PSD1 file. Failing tests." } - $sourceLibRoot = Join-Path $SourceRoot 'Lib' - $hasSourceLibraries = (Test-Path -LiteralPath $sourceLibRoot -PathType Container) -and - (Get-ChildItem -Path $sourceLibRoot -Directory -ErrorAction SilentlyContinue | Select-Object -First 1) - if ($hasSourceLibraries) { - return $SourceRoot - } - $moduleName = $sourceManifest.BaseName - $installedModule = Get-Module -ListAvailable -Name $moduleName | - Sort-Object Version -Descending | - Where-Object { $_.ModuleBase -and $_.ModuleBase -ne $SourceRoot } | - Select-Object -First 1 - if ($installedModule) { - return $installedModule.ModuleBase - } - $artefactModule = Get-ChildItem -Path (Join-Path $SourceRoot 'Artefacts\Unpacked') -Filter '*.psd1' -File -Recurse -ErrorAction SilentlyContinue | Where-Object { $_.BaseName -eq $moduleName } | - Sort-Object FullName -Descending | + Sort-Object -Property @( + @{ Expression = 'LastWriteTimeUtc'; Descending = $true }, + @{ Expression = 'FullName'; Descending = $true } + ) | Select-Object -First 1 if ($artefactModule) { - return Split-Path -Path $artefactModule.FullName -Parent + $artefactRoot = Split-Path -Path $artefactModule.FullName -Parent + if (Test-ModulePayloadUsableForCurrentHost -ModuleRoot $artefactRoot) { + return $artefactRoot + } } - return $SourceRoot -} + if (Test-ModulePayloadUsableForCurrentHost -ModuleRoot $SourceRoot) { + return $SourceRoot + } -$PayloadRoot = Get-ModulePayloadRootForTests -SourceRoot $script:SourceRoot -$sourceLibRoot = Join-Path $script:SourceRoot 'Lib' -$payloadLibRoot = Join-Path $PayloadRoot 'Lib' + if ($env:PSPUBLISHMODULE_TEST_ALLOW_INSTALLED_FALLBACK -eq '1') { + $installedModule = Get-Module -ListAvailable -Name $moduleName | + Sort-Object Version -Descending | + Where-Object { $_.ModuleBase -and $_.ModuleBase -ne $SourceRoot } | + Select-Object -First 1 + if ($installedModule) { + Write-Warning "Falling back to installed module payload for tests: $($installedModule.ModuleBase)" + return $installedModule.ModuleBase + } + } -if (($PayloadRoot -ne $script:SourceRoot) -and (Test-Path -LiteralPath $payloadLibRoot -PathType Container) -and (-not (Test-Path -LiteralPath $sourceLibRoot))) { - Copy-Item -Path $payloadLibRoot -Destination $sourceLibRoot -Recurse -Force - $script:CopiedSourceLib = $true + throw "No usable module payload found for $moduleName on PowerShell edition '$PSEdition'. Build the module first so the host-compatible payload exists." } -$ModuleRoot = $script:SourceRoot -$PrimaryModule = Get-ChildItem -Path $ModuleRoot -Filter '*.psd1' -File -ErrorAction SilentlyContinue | Select-Object -First 1 -if (-not $PrimaryModule) { - throw "Path $ModuleRoot doesn't contain a PSD1 file. Failing tests." +function Get-ResolvedModuleManifestPath { + param( + [Parameter(Mandatory)][string] $ModuleRoot + ) + + $manifestPath = Get-ChildItem -Path $ModuleRoot -Filter '*.psd1' -File -ErrorAction SilentlyContinue | Select-Object -First 1 + if (-not $manifestPath) { + throw "Path $ModuleRoot doesn't contain a PSD1 file. Failing tests." + } + + return $manifestPath.FullName } +$PayloadRoot = Get-ModulePayloadRootForTests -SourceRoot $script:SourceRoot +$ModuleRoot = $PayloadRoot +$PrimaryModulePath = Get-ResolvedModuleManifestPath -ModuleRoot $ModuleRoot +$PrimaryModule = Get-Item -LiteralPath $PrimaryModulePath + $ModuleName = $PrimaryModule.BaseName +$env:PSPUBLISHMODULE_TEST_MANIFEST_PATH = $PrimaryModule.FullName +$env:PSPUBLISHMODULE_TEST_MODULE_ROOT = $ModuleRoot +$env:PSPUBLISHMODULE_TEST_SOURCE_ROOT = $script:SourceRoot $PSDInformation = Import-PowerShellDataFile -Path $PrimaryModule.FullName $RequiredModules = @( 'Pester' @@ -139,7 +172,7 @@ if ($result.FailedCount -gt 0) { } } } finally { - if ($script:CopiedSourceLib -and (Test-Path -LiteralPath $sourceLibRoot)) { - Remove-Item -LiteralPath $sourceLibRoot -Recurse -Force -ErrorAction SilentlyContinue - } + Remove-Item Env:PSPUBLISHMODULE_TEST_MANIFEST_PATH -ErrorAction SilentlyContinue + Remove-Item Env:PSPUBLISHMODULE_TEST_MODULE_ROOT -ErrorAction SilentlyContinue + Remove-Item Env:PSPUBLISHMODULE_TEST_SOURCE_ROOT -ErrorAction SilentlyContinue } diff --git a/Module/Tests/Build-Module.Tests.ps1 b/Module/Tests/Build-Module.Tests.ps1 index 1a2c07f2..de2ce25f 100644 --- a/Module/Tests/Build-Module.Tests.ps1 +++ b/Module/Tests/Build-Module.Tests.ps1 @@ -1,7 +1,7 @@ Describe 'Build-Module' { BeforeAll { # Import the module to make sure all functions are available - $moduleManifest = Join-Path -Path (Join-Path -Path $PSScriptRoot -ChildPath '..') -ChildPath 'PSPublishModule.psd1' + $moduleManifest = if ($env:PSPUBLISHMODULE_TEST_MANIFEST_PATH) { $env:PSPUBLISHMODULE_TEST_MANIFEST_PATH } else { Join-Path -Path (Join-Path -Path $PSScriptRoot -ChildPath '..') -ChildPath 'PSPublishModule.psd1' } Import-Module $moduleManifest -Force # Set up temp directory diff --git a/Module/Tests/Get-ModuleTestFailures.Tests.ps1 b/Module/Tests/Get-ModuleTestFailures.Tests.ps1 index 35cf77a4..9fd5312c 100644 --- a/Module/Tests/Get-ModuleTestFailures.Tests.ps1 +++ b/Module/Tests/Get-ModuleTestFailures.Tests.ps1 @@ -1,7 +1,7 @@ Describe "Get-ModuleTestFailures Tests" { BeforeAll { # Import the local module from the repository (avoid using an installed copy). - $ModulePath = [IO.Path]::Combine($PSScriptRoot, '..', 'PSPublishModule.psd1') + $ModulePath = if ($env:PSPUBLISHMODULE_TEST_MANIFEST_PATH) { $env:PSPUBLISHMODULE_TEST_MANIFEST_PATH } else { [IO.Path]::Combine($PSScriptRoot, '..', 'PSPublishModule.psd1') } Import-Module $ModulePath -Force } diff --git a/Module/Tests/ModuleTestingFunctions.Tests.ps1 b/Module/Tests/ModuleTestingFunctions.Tests.ps1 index 0dbdbee8..98b37296 100644 --- a/Module/Tests/ModuleTestingFunctions.Tests.ps1 +++ b/Module/Tests/ModuleTestingFunctions.Tests.ps1 @@ -4,7 +4,7 @@ Get-Module PSPublishModule | Remove-Module -Force -ErrorAction SilentlyContinue # Import the module fresh - $moduleManifest = Join-Path -Path (Join-Path -Path $PSScriptRoot -ChildPath '..') -ChildPath 'PSPublishModule.psd1' + $moduleManifest = if ($env:PSPUBLISHMODULE_TEST_MANIFEST_PATH) { $env:PSPUBLISHMODULE_TEST_MANIFEST_PATH } else { Join-Path -Path (Join-Path -Path $PSScriptRoot -ChildPath '..') -ChildPath 'PSPublishModule.psd1' } Import-Module $moduleManifest -Force } diff --git a/Module/Tests/PrivateGallery.Commands.Tests.ps1 b/Module/Tests/PrivateGallery.Commands.Tests.ps1 index a4fa4691..a014346c 100644 --- a/Module/Tests/PrivateGallery.Commands.Tests.ps1 +++ b/Module/Tests/PrivateGallery.Commands.Tests.ps1 @@ -2,11 +2,7 @@ Describe 'Private gallery command metadata' { BeforeAll { $existingCommand = Get-Command Connect-ModuleRepository -ErrorAction SilentlyContinue $loadedModule = Get-Module PSPublishModule -ErrorAction SilentlyContinue - $installedModule = Get-Module -ListAvailable PSPublishModule | - Sort-Object Version -Descending | - Select-Object -First 1 - - $moduleManifest = Join-Path -Path (Join-Path -Path $PSScriptRoot -ChildPath '..') -ChildPath 'PSPublishModule.psd1' + $moduleManifest = if ($env:PSPUBLISHMODULE_TEST_MANIFEST_PATH) { $env:PSPUBLISHMODULE_TEST_MANIFEST_PATH } else { Join-Path -Path (Join-Path -Path $PSScriptRoot -ChildPath '..') -ChildPath 'PSPublishModule.psd1' } $runtimesText = (dotnet --list-runtimes 2>$null) -join "`n" $tfm = if ($runtimesText -match '(?m)^Microsoft\.NETCore\.App\s+10\.') { 'net10.0' } else { 'net8.0' } $binaryModule = Join-Path -Path (Join-Path -Path $PSScriptRoot -ChildPath "../../PSPublishModule/bin/Release/$tfm") -ChildPath 'PSPublishModule.dll' @@ -15,8 +11,6 @@ Describe 'Private gallery command metadata' { $script:PrivateGalleryTestModule = $existingCommand.Module } elseif ($loadedModule) { $script:PrivateGalleryTestModule = $loadedModule - } elseif ($installedModule) { - $script:PrivateGalleryTestModule = Import-Module $installedModule.Path -Force -PassThru -ErrorAction Stop } elseif (Test-Path -LiteralPath $binaryModule) { try { $script:PrivateGalleryTestModule = Import-Module $binaryModule -Force -PassThru -ErrorAction Stop diff --git a/Module/Tests/Remove-Comments.Tests.ps1 b/Module/Tests/Remove-Comments.Tests.ps1 index 9e603fcb..478a382e 100644 --- a/Module/Tests/Remove-Comments.Tests.ps1 +++ b/Module/Tests/Remove-Comments.Tests.ps1 @@ -8,9 +8,9 @@ } # Always import the local module from the repository to avoid picking up an installed copy. - $ModuleToLoad = Join-Path -Path $PSScriptRoot -ChildPath '..' -AdditionalChildPath 'PSPublishModule.psd1' - Import-Module $ModuleToLoad -Force - } + $ModuleToLoad = if ($env:PSPUBLISHMODULE_TEST_MANIFEST_PATH) { $env:PSPUBLISHMODULE_TEST_MANIFEST_PATH } else { Join-Path -Path (Join-Path -Path $PSScriptRoot -ChildPath '..') -ChildPath 'PSPublishModule.psd1' } + Import-Module $ModuleToLoad -Force + } It 'Save to variable' { $FilePath = [io.path]::Combine($PSScriptRoot, 'Input', 'RemoveCommentsTests.ps1') diff --git a/Module/Tests/Step-Version.Tests.ps1 b/Module/Tests/Step-Version.Tests.ps1 index fefe9b1b..fc136b8f 100644 --- a/Module/Tests/Step-Version.Tests.ps1 +++ b/Module/Tests/Step-Version.Tests.ps1 @@ -1,13 +1,20 @@ -Describe 'Step-Version' { +Describe 'Step-Version' { + BeforeAll { + $script:ModuleToLoad = if ($env:PSPUBLISHMODULE_TEST_MANIFEST_PATH) { + $env:PSPUBLISHMODULE_TEST_MANIFEST_PATH + } else { + Join-Path -Path (Join-Path -Path $PSScriptRoot -ChildPath '..') -ChildPath 'PSPublishModule.psd1' + } + + Import-Module $script:ModuleToLoad -Force | Out-Null + } + It 'Testing version 0.1.X' { - $ModuleToLoad = Join-Path -Path $PSScriptRoot -ChildPath '..' -AdditionalChildPath 'PSPublishModule.psd1' - Import-Module $ModuleToLoad -Force | Out-Null $Output = Step-Version -Module 'PowerShellManager' -ExpectedVersion '0.1.X' $Output | Should -Be "0.1.3" } - It "Testing version 0.2.X" { - $ModuleToLoad = Join-Path -Path $PSScriptRoot -ChildPath '..' -AdditionalChildPath 'PSPublishModule.psd1' - Import-Module $ModuleToLoad -Force | Out-Null + + It 'Testing version 0.2.X' { $Output = Step-Version -Module 'PowerShellManager' -ExpectedVersion '0.2.X' $Output | Should -Be "0.2.0" } diff --git a/PSPublishModule.sln b/PSPublishModule.sln index fbe99af2..16ab3df4 100644 --- a/PSPublishModule.sln +++ b/PSPublishModule.sln @@ -29,6 +29,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PowerForgeStudio.Cli", "Pow EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PowerForge.PowerShell", "PowerForge.PowerShell\PowerForge.PowerShell.csproj", "{4BA6DB6C-56AF-4C4C-B4CD-506A25DD7A43}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PowerForge.Net472SmokeTests", "PowerForge.Net472SmokeTests\PowerForge.Net472SmokeTests.csproj", "{FD12E32E-6FCF-4D27-86C6-5F153C740F2C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -195,6 +197,18 @@ Global {4BA6DB6C-56AF-4C4C-B4CD-506A25DD7A43}.Release|x64.Build.0 = Release|Any CPU {4BA6DB6C-56AF-4C4C-B4CD-506A25DD7A43}.Release|x86.ActiveCfg = Release|Any CPU {4BA6DB6C-56AF-4C4C-B4CD-506A25DD7A43}.Release|x86.Build.0 = Release|Any CPU + {FD12E32E-6FCF-4D27-86C6-5F153C740F2C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FD12E32E-6FCF-4D27-86C6-5F153C740F2C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FD12E32E-6FCF-4D27-86C6-5F153C740F2C}.Debug|x64.ActiveCfg = Debug|Any CPU + {FD12E32E-6FCF-4D27-86C6-5F153C740F2C}.Debug|x64.Build.0 = Debug|Any CPU + {FD12E32E-6FCF-4D27-86C6-5F153C740F2C}.Debug|x86.ActiveCfg = Debug|Any CPU + {FD12E32E-6FCF-4D27-86C6-5F153C740F2C}.Debug|x86.Build.0 = Debug|Any CPU + {FD12E32E-6FCF-4D27-86C6-5F153C740F2C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FD12E32E-6FCF-4D27-86C6-5F153C740F2C}.Release|Any CPU.Build.0 = Release|Any CPU + {FD12E32E-6FCF-4D27-86C6-5F153C740F2C}.Release|x64.ActiveCfg = Release|Any CPU + {FD12E32E-6FCF-4D27-86C6-5F153C740F2C}.Release|x64.Build.0 = Release|Any CPU + {FD12E32E-6FCF-4D27-86C6-5F153C740F2C}.Release|x86.ActiveCfg = Release|Any CPU + {FD12E32E-6FCF-4D27-86C6-5F153C740F2C}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/PowerForge.Net472SmokeTests/ArtefactConfigurationFactoryNet472SmokeTests.cs b/PowerForge.Net472SmokeTests/ArtefactConfigurationFactoryNet472SmokeTests.cs new file mode 100644 index 00000000..de967d47 --- /dev/null +++ b/PowerForge.Net472SmokeTests/ArtefactConfigurationFactoryNet472SmokeTests.cs @@ -0,0 +1,33 @@ +using PowerForge; + +namespace PowerForge.Net472SmokeTests; + +public sealed class ArtefactConfigurationFactoryNet472SmokeTests +{ + [Fact] + public void Create_NormalizesWindowsPathsUnderNet472() + { + var factory = new ArtefactConfigurationFactory(new NullLogger()); + + var artefact = factory.Create(new ArtefactConfigurationRequest { + Type = ArtefactType.Unpacked, + Path = "output/packages", + RequiredModulesPath = "dependencies/modules", + ModulesPath = "module/content", + CopyFiles = new[] { + new ArtefactCopyMapping { + Source = "src/file.txt", + Destination = "dest/file.txt" + } + } + }); + + Assert.Equal(@"output\packages", artefact.Configuration.Path); + Assert.Equal(@"dependencies\modules", artefact.Configuration.RequiredModules.Path); + Assert.Equal(@"module\content", artefact.Configuration.RequiredModules.ModulesPath); + Assert.NotNull(artefact.Configuration.FilesOutput); + Assert.Single(artefact.Configuration.FilesOutput!); + Assert.Equal(@"src\file.txt", artefact.Configuration.FilesOutput![0].Source); + Assert.Equal(@"dest\file.txt", artefact.Configuration.FilesOutput![0].Destination); + } +} diff --git a/PowerForge.Net472SmokeTests/DotNetNuGetClientNet472SmokeTests.cs b/PowerForge.Net472SmokeTests/DotNetNuGetClientNet472SmokeTests.cs new file mode 100644 index 00000000..99b11baa --- /dev/null +++ b/PowerForge.Net472SmokeTests/DotNetNuGetClientNet472SmokeTests.cs @@ -0,0 +1,59 @@ +using PowerForge; + +namespace PowerForge.Net472SmokeTests; + +public sealed class DotNetNuGetClientNet472SmokeTests +{ + [Fact] + public async Task PushPackageAsync_UsesResponseFileAndCleansItUpUnderNet472() + { + var runtimeRoot = Path.Combine(Path.GetTempPath(), "PowerForge.Net472SmokeTests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(runtimeRoot); + + var packagePath = Path.Combine(runtimeRoot, "sample.1.0.0.nupkg"); + File.WriteAllText(packagePath, "package"); + + ProcessRunRequest? captured = null; + var runner = new StubProcessRunner(request => { + captured = request; + return new ProcessRunResult( + exitCode: 0, + stdOut: "pushed", + stdErr: string.Empty, + executable: "dotnet", + duration: TimeSpan.FromMilliseconds(10), + timedOut: false); + }); + var client = new DotNetNuGetClient(runner, runtimeDirectoryRoot: runtimeRoot); + + var result = await client.PushPackageAsync(new DotNetNuGetPushRequest( + packagePath: packagePath, + apiKey: "secret", + source: "https://api.nuget.org/v3/index.json")); + + Assert.NotNull(captured); + Assert.Equal("dotnet", captured!.FileName); + Assert.Single(captured.Arguments); + Assert.StartsWith("@", captured.Arguments[0], StringComparison.Ordinal); + Assert.False(File.Exists(captured.Arguments[0].Substring(1))); + Assert.True(result.Succeeded); + Assert.Equal("dotnet", result.Executable); + + Directory.Delete(runtimeRoot, recursive: true); + } + + private sealed class StubProcessRunner : IProcessRunner + { + private readonly Func _execute; + + public StubProcessRunner(Func execute) + { + _execute = execute; + } + + public Task RunAsync(ProcessRunRequest request, CancellationToken cancellationToken = default) + { + return Task.FromResult(_execute(request)); + } + } +} diff --git a/PowerForge.Net472SmokeTests/GitClientNet472SmokeTests.cs b/PowerForge.Net472SmokeTests/GitClientNet472SmokeTests.cs new file mode 100644 index 00000000..5c2a791b --- /dev/null +++ b/PowerForge.Net472SmokeTests/GitClientNet472SmokeTests.cs @@ -0,0 +1,51 @@ +using PowerForge; + +namespace PowerForge.Net472SmokeTests; + +public sealed class GitClientNet472SmokeTests +{ + [Fact] + public async Task GetStatusAsync_ParsesBranchCountsUnderNet472() + { + const string output = + "# branch.head main\r\n" + + "# branch.upstream origin/main\r\n" + + "# branch.ab +3 -2\r\n" + + "1 .M N... 100644 100644 100644 123456 123456 src/file.cs\r\n" + + "? notes.txt\r\n"; + + var runner = new StubProcessRunner(_ => new ProcessRunResult( + exitCode: 0, + stdOut: output, + stdErr: string.Empty, + executable: "git", + duration: TimeSpan.FromMilliseconds(25), + timedOut: false)); + var client = new GitClient(runner); + + var snapshot = await client.GetStatusAsync(@"C:\Repo"); + + Assert.True(snapshot.IsGitRepository); + Assert.Equal("main", snapshot.BranchName); + Assert.Equal("origin/main", snapshot.UpstreamBranch); + Assert.Equal(3, snapshot.AheadCount); + Assert.Equal(2, snapshot.BehindCount); + Assert.Equal(1, snapshot.TrackedChangeCount); + Assert.Equal(1, snapshot.UntrackedChangeCount); + } + + private sealed class StubProcessRunner : IProcessRunner + { + private readonly Func _execute; + + public StubProcessRunner(Func execute) + { + _execute = execute; + } + + public Task RunAsync(ProcessRunRequest request, CancellationToken cancellationToken = default) + { + return Task.FromResult(_execute(request)); + } + } +} diff --git a/PowerForge.Net472SmokeTests/PowerForge.Net472SmokeTests.csproj b/PowerForge.Net472SmokeTests/PowerForge.Net472SmokeTests.csproj new file mode 100644 index 00000000..3824d2e5 --- /dev/null +++ b/PowerForge.Net472SmokeTests/PowerForge.Net472SmokeTests.csproj @@ -0,0 +1,27 @@ + + + + net472 + enable + enable + latest + false + + + + + + + + + + + + + + + + + + + diff --git a/PowerForge.Net472SmokeTests/ProjectBuildCommandHostServiceNet472SmokeTests.cs b/PowerForge.Net472SmokeTests/ProjectBuildCommandHostServiceNet472SmokeTests.cs new file mode 100644 index 00000000..95ad9c19 --- /dev/null +++ b/PowerForge.Net472SmokeTests/ProjectBuildCommandHostServiceNet472SmokeTests.cs @@ -0,0 +1,55 @@ +using PowerForge; + +namespace PowerForge.Net472SmokeTests; + +public sealed class ProjectBuildCommandHostServiceNet472SmokeTests +{ + [Fact] + public async Task GeneratePlanAsync_BuildsExpectedPowerShellCommandUnderNet472() + { + var tempRoot = Path.Combine(Path.GetTempPath(), "PowerForge.Net472SmokeTests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempRoot); + var modulePath = Path.Combine(tempRoot, "PSPublishModule.psd1"); + File.WriteAllText(modulePath, "@{}"); + + PowerShellRunRequest? captured = null; + var service = new ProjectBuildCommandHostService(new StubPowerShellRunner(request => { + captured = request; + return new PowerShellRunResult(0, "planned", string.Empty, "powershell.exe"); + })); + + var result = await service.GeneratePlanAsync(new ProjectBuildCommandPlanRequest { + RepositoryRoot = @"C:\Repo", + PlanOutputPath = @"C:\Repo\plan.json", + ConfigPath = @"C:\Repo\Build\project.build.json", + ModulePath = modulePath + }); + + Assert.True(result.Succeeded); + Assert.NotNull(captured); + Assert.Equal(PowerShellInvocationMode.Command, captured!.InvocationMode); + Assert.Equal(@"C:\Repo", captured.WorkingDirectory); + Assert.Contains("Import-Module", captured.CommandText, StringComparison.Ordinal); + Assert.Contains($"'{modulePath}'", captured.CommandText, StringComparison.Ordinal); + Assert.Contains("Set-Location -LiteralPath 'C:\\Repo'", captured.CommandText, StringComparison.Ordinal); + Assert.Contains("Invoke-ProjectBuild -Plan:$true -PlanPath 'C:\\Repo\\plan.json'", captured.CommandText, StringComparison.Ordinal); + Assert.Contains("-ConfigPath 'C:\\Repo\\Build\\project.build.json'", captured.CommandText, StringComparison.Ordinal); + + Directory.Delete(tempRoot, recursive: true); + } + + private sealed class StubPowerShellRunner : IPowerShellRunner + { + private readonly Func _execute; + + public StubPowerShellRunner(Func execute) + { + _execute = execute; + } + + public PowerShellRunResult Run(PowerShellRunRequest request) + { + return _execute(request); + } + } +} diff --git a/PowerForge.Net472SmokeTests/packages.lock.json b/PowerForge.Net472SmokeTests/packages.lock.json new file mode 100644 index 00000000..127bee51 --- /dev/null +++ b/PowerForge.Net472SmokeTests/packages.lock.json @@ -0,0 +1,213 @@ +{ + "version": 1, + "dependencies": { + ".NETFramework,Version=v4.7.2": { + "coverlet.collector": { + "type": "Direct", + "requested": "[6.0.4, )", + "resolved": "6.0.4", + "contentHash": "lkhqpF8Pu2Y7IiN7OntbsTtdbpR1syMsm2F3IgX6ootA4ffRqWL5jF7XipHuZQTdVuWG/gVAAcf8mjk8Tz0xPg==" + }, + "Microsoft.NET.Test.Sdk": { + "type": "Direct", + "requested": "[17.14.1, )", + "resolved": "17.14.1", + "contentHash": "HJKqKOE+vshXra2aEHpi2TlxYX7Z9VFYkr+E5rwEvHC8eIXiyO+K9kNm8vmNom3e2rA56WqxU+/N9NJlLGXsJQ==", + "dependencies": { + "Microsoft.CodeCoverage": "17.14.1" + } + }, + "xunit": { + "type": "Direct", + "requested": "[2.9.3, )", + "resolved": "2.9.3", + "contentHash": "TlXQBinK35LpOPKHAqbLY4xlEen9TBafjs0V5KnA4wZsoQLQJiirCR4CbIXvOH8NzkW4YeJKP5P/Bnrodm0h9Q==", + "dependencies": { + "xunit.analyzers": "1.18.0", + "xunit.assert": "2.9.3", + "xunit.core": "[2.9.3]" + } + }, + "xunit.runner.visualstudio": { + "type": "Direct", + "requested": "[3.1.4, )", + "resolved": "3.1.4", + "contentHash": "5mj99LvCqrq3CNi06xYdyIAXOEh+5b33F2nErCzI5zWiDdLHXiPXEWFSUAF8zlIv0ZWqjZNCwHTQeAPYbF3pCg==", + "dependencies": { + "Microsoft.TestPlatform.ObjectModel": "17.13.0" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "owmu2Cr3IQ8yQiBleBHlGk8dSQ12oaF2e7TpzwJKEl4m84kkZJjEY1n33L67Y3zM5jPOjmmbdHjbfiL0RqcMRQ==", + "dependencies": { + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "Microsoft.CodeCoverage": { + "type": "Transitive", + "resolved": "17.14.1", + "contentHash": "pmTrhfFIoplzFVbhVwUquT+77CbGH+h4/3mBpdmIlYtBi9nAB+kKI6dN3A/nV4DFi3wLLx/BlHIPK+MkbQ6Tpg==" + }, + "Microsoft.PowerShell.5.ReferenceAssemblies": { + "type": "Transitive", + "resolved": "1.1.0", + "contentHash": "EE87t3aUXlO0Rjq83Ti8z1g4wwsWnB4W+pOn84i3QWzUOOuP5gZg8n4Y8XTZ7GkxGcoSR6w/d/kBJTJbc3VZPQ==" + }, + "Microsoft.TestPlatform.ObjectModel": { + "type": "Transitive", + "resolved": "17.13.0", + "contentHash": "bt0E0Dx+iqW97o4A59RCmUmz/5NarJ7LRL+jXbSHod72ibL5XdNm1Ke+UO5tFhBG4VwHLcSjqq9BUSblGNWamw==", + "dependencies": { + "System.Reflection.Metadata": "1.6.0" + } + }, + "System.Buffers": { + "type": "Transitive", + "resolved": "4.5.1", + "contentHash": "Rw7ijyl1qqRS0YQD/WycNst8hUUMgrMH4FCn1nNm27M4VxchZ1js3fVjQaANHO5f3sN4isvP4a+Met9Y4YomAg==" + }, + "System.Collections.Immutable": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "AurL6Y5BA1WotzlEvVaIDpqzpIPvYnnldxru8oXJU2yFxFUy3+pNXjXd1ymO+RA0rq0+590Q8gaz2l3Sr7fmqg==", + "dependencies": { + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.IO.Pipelines": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "eA3cinogwaNB4jdjQHOP3Z3EuyiDII7MT35jgtnsA4vkn0LUrrSHsU0nzHTzFzmaFYeKV7MYyMxOocFzsBHpTw==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5", + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.5.5", + "contentHash": "XIWiDvKPXaTveaB7HVganDlOCRoj03l+jrwNvcge/t8vhGYKvqV+dMv6G4SAX2NoNmN0wZfVPTAlFwZcZvVOUw==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Numerics.Vectors": "4.5.0", + "System.Runtime.CompilerServices.Unsafe": "4.5.3" + } + }, + "System.Numerics.Vectors": { + "type": "Transitive", + "resolved": "4.5.0", + "contentHash": "QQTlPTl06J/iiDbJCiepZ4H//BVraReU4O4EoRw1U02H5TLUIT7xn3GnDp9AXPSlJUDyFs4uWjWafNX6WrAojQ==" + }, + "System.Reflection.Metadata": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "ptvgrFh7PvWI8bcVqG5rsA/weWM09EnthFHR5SCnS6IN+P4mj6rE1lBDC4U8HL9/57htKAqy4KQ3bBj84cfYyQ==", + "dependencies": { + "System.Collections.Immutable": "8.0.0", + "System.Memory": "4.5.5" + } + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "e2hMgAErLbKyUUwt18qSBf9T5Y+SFAL3ZedM8fLupkVj8Rj2PZ9oxQ37XX2LF8fTO1wNIxvKpihD7Of7D/NxZw==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Text.Json": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "js7+qAu/9mQvnhA4EfGMZNEzXtJCDxgkgj8ohuxq/Qxv+R56G+ljefhiJHOxTNiw54q8vmABCWUwkMulNdlZ4A==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "9.0.0", + "System.Buffers": "4.5.1", + "System.IO.Pipelines": "9.0.0", + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0", + "System.Text.Encodings.Web": "9.0.0", + "System.Threading.Tasks.Extensions": "4.5.4", + "System.ValueTuple": "4.5.0" + } + }, + "System.Threading.Tasks.Extensions": { + "type": "Transitive", + "resolved": "4.5.4", + "contentHash": "zteT+G8xuGu6mS+mzDzYXbzS7rd3K6Fjb9RiZlYlJPam2/hU7JCBZBVEcywNuR+oZ1ncTvc/cq0faRr3P01OVg==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "4.5.3" + } + }, + "System.ValueTuple": { + "type": "Transitive", + "resolved": "4.5.0", + "contentHash": "okurQJO6NRE/apDIP23ajJ0hpiNmJ+f0BwOlB/cSqTLQlw5upkf+5+96+iG2Jw40G1fCVCyPz/FhIABUjMR+RQ==" + }, + "xunit.abstractions": { + "type": "Transitive", + "resolved": "2.0.3", + "contentHash": "pot1I4YOxlWjIb5jmwvvQNbTrZ3lJQ+jUGkGjWE3hEFM0l5gOnBWS+H3qsex68s5cO52g+44vpGzhAt+42vwKg==" + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "1.18.0", + "contentHash": "OtFMHN8yqIcYP9wcVIgJrq01AfTxijjAqVDy/WeQVSyrDC1RzBWeQPztL49DN2syXRah8TYnfvk035s7L95EZQ==" + }, + "xunit.assert": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "/Kq28fCE7MjOV42YLVRAJzRF0WmEqsmflm0cfpMjGtzQ2lR5mYVj1/i0Y8uDAOLczkL3/jArrwehfMD0YogMAA==" + }, + "xunit.core": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "BiAEvqGvyme19wE0wTKdADH+NloYqikiU0mcnmiNyXaF9HyHmE6sr/3DC5vnBkgsWaE6yPyWszKSPSApWdRVeQ==", + "dependencies": { + "xunit.extensibility.core": "[2.9.3]", + "xunit.extensibility.execution": "[2.9.3]" + } + }, + "xunit.extensibility.core": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "kf3si0YTn2a8J8eZNb+zFpwfoyvIrQ7ivNk5ZYA5yuYk1bEtMe4DxJ2CF/qsRgmEnDr7MnW1mxylBaHTZ4qErA==", + "dependencies": { + "xunit.abstractions": "2.0.3" + } + }, + "xunit.extensibility.execution": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "yMb6vMESlSrE3Wfj7V6cjQ3S4TXdXpRqYeNEI3zsX31uTsGMJjEw6oD5F5u1cHnMptjhEECnmZSsPxB6ChZHDQ==", + "dependencies": { + "xunit.extensibility.core": "[2.9.3]" + } + }, + "powerforge": { + "type": "Project", + "dependencies": { + "System.Reflection.Metadata": "[8.0.0, )", + "System.Text.Json": "[9.0.0, )" + } + }, + "powerforge.powershell": { + "type": "Project", + "dependencies": { + "Microsoft.PowerShell.5.ReferenceAssemblies": "[1.1.0, )", + "PowerForge": "[1.0.0, )" + } + } + } + } +} \ No newline at end of file diff --git a/PowerForge/Abstractions/IPowerShellRunner.cs b/PowerForge/Abstractions/IPowerShellRunner.cs index 29254d6e..f48fa689 100644 --- a/PowerForge/Abstractions/IPowerShellRunner.cs +++ b/PowerForge/Abstractions/IPowerShellRunner.cs @@ -195,7 +195,7 @@ public PowerShellRunResult Run(PowerShellRunRequest request) { if (!string.IsNullOrWhiteSpace(executableOverride)) { - var overridePath = ResolveOnPath(executableOverride); + var overridePath = ResolveOnPath(executableOverride!); if (overridePath is not null) return overridePath; if (File.Exists(executableOverride)) diff --git a/PowerForge/FrameworkCompatibility.cs b/PowerForge/FrameworkCompatibility.cs new file mode 100644 index 00000000..c7d6bafd --- /dev/null +++ b/PowerForge/FrameworkCompatibility.cs @@ -0,0 +1,72 @@ +using System.Net.Http; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; + +namespace PowerForge; + +internal static class FrameworkCompatibility +{ + public static T NotNull(T value, string paramName) where T : class + { + if (value is null) + throw new ArgumentNullException(paramName); + + return value; + } + + public static string NotNullOrWhiteSpace(string value, string paramName) + { + if (string.IsNullOrWhiteSpace(value)) + throw new ArgumentException("Value is required.", paramName); + + return value; + } + + public static bool IsWindows() + { +#if NET472 + return true; +#else + return OperatingSystem.IsWindows(); +#endif + } + + public static string GetRelativePath(string relativeTo, string path) + { +#if NET472 + var basePath = Path.GetFullPath(relativeTo) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + + Path.DirectorySeparatorChar; + var baseUri = new Uri(basePath); + var targetUri = new Uri(Path.GetFullPath(path)); + // Note: this URI-based fallback does not round-trip literal '%' path segments on .NET Framework. + return Uri.UnescapeDataString(baseUri.MakeRelativeUri(targetUri).ToString()) + .Replace('/', Path.DirectorySeparatorChar); +#else + return Path.GetRelativePath(relativeTo, path); +#endif + } + + public static string GetSha256Hex(X509Certificate2 certificate) + { +#if NET472 + using var sha = SHA256.Create(); + var hash = sha.ComputeHash(certificate.RawData); + return BitConverter.ToString(hash).Replace("-", string.Empty).ToUpperInvariant(); +#else + return certificate.GetCertHashString(HashAlgorithmName.SHA256); +#endif + } + + public static Task ReadAsStreamAsync(HttpContent content, CancellationToken cancellationToken) + { +#if NET472 + // NET472: HttpContent.ReadAsStreamAsync does not accept a CancellationToken. + // Cancellation is only checked eagerly before the read begins. + cancellationToken.ThrowIfCancellationRequested(); + return content.ReadAsStreamAsync(); +#else + return content.ReadAsStreamAsync(cancellationToken); +#endif + } +} diff --git a/PowerForge/InternalsVisibleTo.cs b/PowerForge/InternalsVisibleTo.cs index 5612601b..bb9dcc68 100644 --- a/PowerForge/InternalsVisibleTo.cs +++ b/PowerForge/InternalsVisibleTo.cs @@ -1,6 +1,7 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("PowerForge.Tests")] +[assembly: InternalsVisibleTo("PowerForge.Net472SmokeTests")] [assembly: InternalsVisibleTo("PowerForge.Cli")] [assembly: InternalsVisibleTo("PowerForge.PowerShell")] [assembly: InternalsVisibleTo("PowerForgeStudio.Tests")] diff --git a/PowerForge/Services/AuthenticodeSigningHostService.cs b/PowerForge/Services/AuthenticodeSigningHostService.cs index d7c140e6..6179b772 100644 --- a/PowerForge/Services/AuthenticodeSigningHostService.cs +++ b/PowerForge/Services/AuthenticodeSigningHostService.cs @@ -27,7 +27,7 @@ internal AuthenticodeSigningHostService(IPowerShellRunner powerShellRunner) /// public async Task SignAsync(AuthenticodeSigningHostRequest request, CancellationToken cancellationToken = default) { - ArgumentNullException.ThrowIfNull(request); + FrameworkCompatibility.NotNull(request, nameof(request)); ValidateRequired(request.SigningPath, nameof(request.SigningPath)); ValidateRequired(request.ModulePath, nameof(request.ModulePath)); ValidateRequired(request.Thumbprint, nameof(request.Thumbprint)); @@ -45,7 +45,7 @@ public async Task SignAsync(AuthenticodeSigningHo var result = await Task.Run(() => _powerShellRunner.Run(PowerShellRunRequest.ForCommand( commandText: script, timeout: TimeSpan.FromMinutes(15), - preferPwsh: !OperatingSystem.IsWindows(), + preferPwsh: !FrameworkCompatibility.IsWindows(), workingDirectory: request.SigningPath, executableOverride: Environment.GetEnvironmentVariable("RELEASE_OPS_STUDIO_POWERSHELL_EXE"))), cancellationToken).ConfigureAwait(false); startedAt.Stop(); diff --git a/PowerForge/Services/CertificateFingerprintResolver.cs b/PowerForge/Services/CertificateFingerprintResolver.cs index b87e280b..c8da539b 100644 --- a/PowerForge/Services/CertificateFingerprintResolver.cs +++ b/PowerForge/Services/CertificateFingerprintResolver.cs @@ -49,7 +49,7 @@ internal CertificateFingerprintResolver(Func res store.Open(OpenFlags.ReadOnly); var certificate = store.Certificates.Cast() .FirstOrDefault(candidate => NormalizeThumbprint(candidate.Thumbprint) == normalizedThumbprint); - return certificate?.GetCertHashString(HashAlgorithmName.SHA256); + return certificate is null ? null : FrameworkCompatibility.GetSha256Hex(certificate); } private static StoreLocation ParseStoreLocation(string storeName) diff --git a/PowerForge/Services/DotNetNuGetClient.cs b/PowerForge/Services/DotNetNuGetClient.cs index 2914a021..884a0448 100644 --- a/PowerForge/Services/DotNetNuGetClient.cs +++ b/PowerForge/Services/DotNetNuGetClient.cs @@ -26,9 +26,7 @@ public DotNetNuGetClient( _processRunner = processRunner ?? new ProcessRunner(); _dotNetExecutable = string.IsNullOrWhiteSpace(dotNetExecutable) ? "dotnet" : dotNetExecutable; _defaultTimeout = defaultTimeout ?? TimeSpan.FromMinutes(10); - _runtimeDirectoryRoot = string.IsNullOrWhiteSpace(runtimeDirectoryRoot) - ? Path.Combine(Path.GetTempPath(), "PowerForge", "runtime", "dotnet-nuget") - : runtimeDirectoryRoot; + _runtimeDirectoryRoot = NormalizeRuntimeDirectoryRoot(runtimeDirectoryRoot); } /// @@ -159,11 +157,11 @@ private static string BuildPushResponseFileContent(DotNetNuGetPushRequest reques private static string ResolveWorkingDirectory(string? workingDirectory, string packagePath) { if (!string.IsNullOrWhiteSpace(workingDirectory)) - return workingDirectory; + return workingDirectory!; var packageDirectory = Path.GetDirectoryName(packagePath); if (!string.IsNullOrWhiteSpace(packageDirectory)) - return packageDirectory; + return packageDirectory!; return Environment.CurrentDirectory; } @@ -189,6 +187,16 @@ private static void TryDeleteFile(string path) if (string.IsNullOrWhiteSpace(value)) return null; - return value.Split(["\r\n", "\n"], StringSplitOptions.RemoveEmptyEntries).FirstOrDefault()?.Trim(); + var nonEmptyValue = value!; + + return nonEmptyValue + .Split(new[] { "\r\n", "\n" }, StringSplitOptions.RemoveEmptyEntries) + .FirstOrDefault() + ?.Trim(); } + + private static string NormalizeRuntimeDirectoryRoot(string? runtimeDirectoryRoot) + => string.IsNullOrWhiteSpace(runtimeDirectoryRoot) + ? Path.Combine(Path.GetTempPath(), "PowerForge", "runtime", "dotnet-nuget") + : runtimeDirectoryRoot!; } diff --git a/PowerForge/Services/GitClient.cs b/PowerForge/Services/GitClient.cs index 380df8b6..7e5724c6 100644 --- a/PowerForge/Services/GitClient.cs +++ b/PowerForge/Services/GitClient.cs @@ -164,23 +164,23 @@ private static GitStatusSnapshot ParseStatus(GitCommandResult result) var trackedChangeCount = 0; var untrackedChangeCount = 0; - foreach (var line in result.StdOut.Split(["\r\n", "\n"], StringSplitOptions.RemoveEmptyEntries)) + foreach (var line in result.StdOut.Split(new[] { "\r\n", "\n" }, StringSplitOptions.RemoveEmptyEntries)) { if (line.StartsWith("# branch.head ", StringComparison.Ordinal)) { - branchName = line["# branch.head ".Length..].Trim(); + branchName = line.Substring("# branch.head ".Length).Trim(); continue; } if (line.StartsWith("# branch.upstream ", StringComparison.Ordinal)) { - upstreamBranch = line["# branch.upstream ".Length..].Trim(); + upstreamBranch = line.Substring("# branch.upstream ".Length).Trim(); continue; } if (line.StartsWith("# branch.ab ", StringComparison.Ordinal)) { - ParseAheadBehind(line["# branch.ab ".Length..], out aheadCount, out behindCount); + ParseAheadBehind(line.Substring("# branch.ab ".Length), out aheadCount, out behindCount); continue; } @@ -206,13 +206,13 @@ private static void ParseAheadBehind(string value, out int aheadCount, out int b aheadCount = 0; behindCount = 0; - var parts = value.Split(' ', StringSplitOptions.RemoveEmptyEntries); + var parts = value.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); foreach (var part in parts) { - if (part.StartsWith('+') && int.TryParse(part[1..], out var ahead)) + if (part.StartsWith("+", StringComparison.Ordinal) && int.TryParse(part.Substring(1), out var ahead)) aheadCount = ahead; - if (part.StartsWith('-') && int.TryParse(part[1..], out var behind)) + if (part.StartsWith("-", StringComparison.Ordinal) && int.TryParse(part.Substring(1), out var behind)) behindCount = behind; } } diff --git a/PowerForge/Services/ModuleBuildHostService.cs b/PowerForge/Services/ModuleBuildHostService.cs index 2457d9d8..c0738e61 100644 --- a/PowerForge/Services/ModuleBuildHostService.cs +++ b/PowerForge/Services/ModuleBuildHostService.cs @@ -28,7 +28,7 @@ internal ModuleBuildHostService(IPowerShellRunner powerShellRunner) /// public Task ExportPipelineJsonAsync(ModuleBuildHostExportRequest request, CancellationToken cancellationToken = default) { - ArgumentNullException.ThrowIfNull(request); + FrameworkCompatibility.NotNull(request, nameof(request)); ValidateRequiredPath(request.RepositoryRoot, nameof(request.RepositoryRoot)); ValidateRequiredPath(request.ScriptPath, nameof(request.ScriptPath)); ValidateRequiredPath(request.ModulePath, nameof(request.ModulePath)); @@ -43,7 +43,7 @@ public Task ExportPipelineJsonAsync(ModuleBuildH /// public Task ExecuteBuildAsync(ModuleBuildHostBuildRequest request, CancellationToken cancellationToken = default) { - ArgumentNullException.ThrowIfNull(request); + FrameworkCompatibility.NotNull(request, nameof(request)); ValidateRequiredPath(request.RepositoryRoot, nameof(request.RepositoryRoot)); ValidateRequiredPath(request.ScriptPath, nameof(request.ScriptPath)); ValidateRequiredPath(request.ModulePath, nameof(request.ModulePath)); @@ -58,7 +58,7 @@ private async Task RunCommandAsync(string workin var result = await Task.Run(() => _powerShellRunner.Run(PowerShellRunRequest.ForCommand( commandText: script, timeout: TimeSpan.FromMinutes(15), - preferPwsh: !OperatingSystem.IsWindows(), + preferPwsh: !FrameworkCompatibility.IsWindows(), workingDirectory: workingDirectory, executableOverride: Environment.GetEnvironmentVariable("RELEASE_OPS_STUDIO_POWERSHELL_EXE"))), cancellationToken).ConfigureAwait(false); startedAt.Stop(); diff --git a/PowerForge/Services/ModulePublishTagBuilder.cs b/PowerForge/Services/ModulePublishTagBuilder.cs index 54665396..d4ba2455 100644 --- a/PowerForge/Services/ModulePublishTagBuilder.cs +++ b/PowerForge/Services/ModulePublishTagBuilder.cs @@ -15,7 +15,7 @@ public sealed class ModulePublishTagBuilder /// Resolved tag name. public string BuildTag(PublishConfiguration publish, string moduleName, string resolvedVersion, string? preRelease) { - ArgumentNullException.ThrowIfNull(publish); + FrameworkCompatibility.NotNull(publish, nameof(publish)); var versionWithPreRelease = string.IsNullOrWhiteSpace(preRelease) ? resolvedVersion diff --git a/PowerForge/Services/PowerForgeToolReleaseService.cs b/PowerForge/Services/PowerForgeToolReleaseService.cs index 304bdf53..5ced48b4 100644 --- a/PowerForge/Services/PowerForgeToolReleaseService.cs +++ b/PowerForge/Services/PowerForgeToolReleaseService.cs @@ -573,13 +573,13 @@ private static void CopyDirectoryContents(string source, string destination) Directory.CreateDirectory(destination); foreach (var directory in Directory.GetDirectories(source, "*", SearchOption.AllDirectories)) { - var relative = Path.GetRelativePath(source, directory); + var relative = FrameworkCompatibility.GetRelativePath(source, directory); Directory.CreateDirectory(Path.Combine(destination, relative)); } foreach (var file in Directory.GetFiles(source, "*", SearchOption.AllDirectories)) { - var relative = Path.GetRelativePath(source, file); + var relative = FrameworkCompatibility.GetRelativePath(source, file); var targetPath = Path.Combine(destination, relative); Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); File.Copy(file, targetPath, overwrite: true); diff --git a/PowerForge/Services/PowerShellRepositoryResolver.cs b/PowerForge/Services/PowerShellRepositoryResolver.cs index da7771a7..dded5b82 100644 --- a/PowerForge/Services/PowerShellRepositoryResolver.cs +++ b/PowerForge/Services/PowerShellRepositoryResolver.cs @@ -32,8 +32,8 @@ internal PowerShellRepositoryResolver(IPowerShellRunner powerShellRunner) /// public async Task ResolveAsync(string workingDirectory, string repositoryName, CancellationToken cancellationToken = default) { - ArgumentException.ThrowIfNullOrWhiteSpace(workingDirectory); - ArgumentException.ThrowIfNullOrWhiteSpace(repositoryName); + FrameworkCompatibility.NotNullOrWhiteSpace(workingDirectory, nameof(workingDirectory)); + FrameworkCompatibility.NotNullOrWhiteSpace(repositoryName, nameof(repositoryName)); if (Uri.TryCreate(repositoryName, UriKind.Absolute, out var directUri)) { @@ -66,7 +66,7 @@ internal PowerShellRepositoryResolver(IPowerShellRunner powerShellRunner) var result = await Task.Run(() => _powerShellRunner.Run(PowerShellRunRequest.ForCommand( commandText: script, timeout: TimeSpan.FromMinutes(2), - preferPwsh: !OperatingSystem.IsWindows(), + preferPwsh: !FrameworkCompatibility.IsWindows(), workingDirectory: workingDirectory, executableOverride: Environment.GetEnvironmentVariable("RELEASE_OPS_STUDIO_POWERSHELL_EXE"))), cancellationToken).ConfigureAwait(false); startedAt.Stop(); diff --git a/PowerForge/Services/ProjectBuildCommandHostService.cs b/PowerForge/Services/ProjectBuildCommandHostService.cs index 61bd9d4f..01aaf59a 100644 --- a/PowerForge/Services/ProjectBuildCommandHostService.cs +++ b/PowerForge/Services/ProjectBuildCommandHostService.cs @@ -28,7 +28,7 @@ internal ProjectBuildCommandHostService(IPowerShellRunner powerShellRunner) /// public Task GeneratePlanAsync(ProjectBuildCommandPlanRequest request, CancellationToken cancellationToken = default) { - ArgumentNullException.ThrowIfNull(request); + FrameworkCompatibility.NotNull(request, nameof(request)); ValidateRequiredPath(request.RepositoryRoot, nameof(request.RepositoryRoot)); ValidateRequiredPath(request.PlanOutputPath, nameof(request.PlanOutputPath)); ValidateRequiredPath(request.ModulePath, nameof(request.ModulePath)); @@ -37,7 +37,8 @@ public Task GeneratePlanAsync(ProjectBui command.Append(QuoteLiteral(request.PlanOutputPath)); if (!string.IsNullOrWhiteSpace(request.ConfigPath)) { - command.Append(" -ConfigPath ").Append(QuoteLiteral(request.ConfigPath)); + var configPath = request.ConfigPath!; + command.Append(" -ConfigPath ").Append(QuoteLiteral(configPath)); } return RunCommandAsync( @@ -51,14 +52,15 @@ public Task GeneratePlanAsync(ProjectBui /// public Task ExecuteBuildAsync(ProjectBuildCommandBuildRequest request, CancellationToken cancellationToken = default) { - ArgumentNullException.ThrowIfNull(request); + FrameworkCompatibility.NotNull(request, nameof(request)); ValidateRequiredPath(request.RepositoryRoot, nameof(request.RepositoryRoot)); ValidateRequiredPath(request.ModulePath, nameof(request.ModulePath)); var command = new StringBuilder("Invoke-ProjectBuild -Build:$true -PublishNuget:$false -PublishGitHub:$false -UpdateVersions:$false"); if (!string.IsNullOrWhiteSpace(request.ConfigPath)) { - command.Append(" -ConfigPath ").Append(QuoteLiteral(request.ConfigPath)); + var configPath = request.ConfigPath!; + command.Append(" -ConfigPath ").Append(QuoteLiteral(configPath)); } return RunCommandAsync( @@ -73,7 +75,7 @@ private async Task RunCommandAsync(strin var result = await Task.Run(() => _powerShellRunner.Run(PowerShellRunRequest.ForCommand( commandText: script, timeout: TimeSpan.FromMinutes(15), - preferPwsh: !OperatingSystem.IsWindows(), + preferPwsh: !FrameworkCompatibility.IsWindows(), workingDirectory: workingDirectory, executableOverride: Environment.GetEnvironmentVariable("RELEASE_OPS_STUDIO_POWERSHELL_EXE"))), cancellationToken).ConfigureAwait(false); startedAt.Stop(); diff --git a/PowerForge/Services/ProjectBuildPublishHostService.cs b/PowerForge/Services/ProjectBuildPublishHostService.cs index b48f2dc1..3509ff0e 100644 --- a/PowerForge/Services/ProjectBuildPublishHostService.cs +++ b/PowerForge/Services/ProjectBuildPublishHostService.cs @@ -37,7 +37,7 @@ internal ProjectBuildPublishHostService( /// public ProjectBuildPublishHostConfiguration LoadConfiguration(string configPath) { - ArgumentException.ThrowIfNullOrWhiteSpace(configPath); + FrameworkCompatibility.NotNullOrWhiteSpace(configPath, nameof(configPath)); var resolvedConfigPath = Path.GetFullPath(configPath.Trim().Trim('"')); var configDirectory = Path.GetDirectoryName(resolvedConfigPath); @@ -45,13 +45,17 @@ public ProjectBuildPublishHostConfiguration LoadConfiguration(string configPath) throw new InvalidOperationException($"Unable to resolve the configuration directory for '{resolvedConfigPath}'."); var config = new ProjectBuildSupportService(_logger).LoadConfig(resolvedConfigPath); + var publishSource = string.IsNullOrWhiteSpace(config.PublishSource) + ? "https://api.nuget.org/v3/index.json" + : config.PublishSource!.Trim(); + var releaseMode = string.IsNullOrWhiteSpace(config.GitHubReleaseMode) + ? "Single" + : config.GitHubReleaseMode!.Trim(); return new ProjectBuildPublishHostConfiguration { ConfigPath = resolvedConfigPath, PublishNuget = config.PublishNuget == true, PublishGitHub = config.PublishGitHub == true, - PublishSource = string.IsNullOrWhiteSpace(config.PublishSource) - ? "https://api.nuget.org/v3/index.json" - : config.PublishSource.Trim(), + PublishSource = publishSource, PublishApiKey = ProjectBuildSupportService.ResolveSecret( config.PublishApiKey, config.PublishApiKeyFilePath, @@ -70,7 +74,7 @@ public ProjectBuildPublishHostConfiguration LoadConfiguration(string configPath) GitHubReleaseName = TrimOrNull(config.GitHubReleaseName), GitHubTagName = TrimOrNull(config.GitHubTagName), GitHubTagTemplate = TrimOrNull(config.GitHubTagTemplate), - GitHubReleaseMode = string.IsNullOrWhiteSpace(config.GitHubReleaseMode) ? "Single" : config.GitHubReleaseMode.Trim(), + GitHubReleaseMode = releaseMode, GitHubPrimaryProject = TrimOrNull(config.GitHubPrimaryProject), GitHubTagConflictPolicy = TrimOrNull(config.GitHubTagConflictPolicy) }; @@ -81,8 +85,8 @@ public ProjectBuildPublishHostConfiguration LoadConfiguration(string configPath) /// public ProjectBuildGitHubPublishSummary PublishGitHub(ProjectBuildPublishHostConfiguration configuration, DotNetRepositoryReleaseResult release) { - ArgumentNullException.ThrowIfNull(configuration); - ArgumentNullException.ThrowIfNull(release); + FrameworkCompatibility.NotNull(configuration, nameof(configuration)); + FrameworkCompatibility.NotNull(release, nameof(release)); var request = new ProjectBuildGitHubPublishRequest { Owner = configuration.GitHubUsername ?? string.Empty, @@ -104,5 +108,5 @@ public ProjectBuildGitHubPublishSummary PublishGitHub(ProjectBuildPublishHostCon } private static string? TrimOrNull(string? value) - => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + => string.IsNullOrWhiteSpace(value) ? null : value!.Trim(); } diff --git a/PowerForge/Services/PublishVerificationHostService.cs b/PowerForge/Services/PublishVerificationHostService.cs index c565854e..0ed72fc2 100644 --- a/PowerForge/Services/PublishVerificationHostService.cs +++ b/PowerForge/Services/PublishVerificationHostService.cs @@ -76,7 +76,7 @@ internal PublishVerificationHostService( /// public Task VerifyAsync(PublishVerificationRequest request, CancellationToken cancellationToken = default) { - ArgumentNullException.ThrowIfNull(request); + FrameworkCompatibility.NotNull(request, nameof(request)); return request.TargetKind switch { @@ -128,13 +128,15 @@ private async Task VerifyNuGetAsync(PublishVerificati return Skipped("NuGet destination URL was not recorded, so remote verification was skipped."); } - var identity = TryReadPackageIdentity(request.SourcePath); + var sourcePath = request.SourcePath!; + var identity = TryReadPackageIdentity(sourcePath); if (identity is null) { return Failed("NuGet package identity could not be read from the .nupkg."); } - var probeUri = await ResolveNuGetPackageProbeUriAsync(request.Destination, identity, cancellationToken).ConfigureAwait(false); + var destination = request.Destination!; + var probeUri = await ResolveNuGetPackageProbeUriAsync(destination, identity, cancellationToken).ConfigureAwait(false); if (probeUri is null) { return Skipped($"PowerForgeStudio could not derive a probeable package endpoint from {request.Destination}."); @@ -252,7 +254,7 @@ private async Task VerifyPowerShellRepositoryAsync(Pu return null; } - await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + using var stream = await FrameworkCompatibility.ReadAsStreamAsync(response.Content, cancellationToken).ConfigureAwait(false); using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false); if (!document.RootElement.TryGetProperty("resources", out var resources) || resources.ValueKind != JsonValueKind.Array) { @@ -268,7 +270,7 @@ private async Task VerifyPowerShellRepositoryAsync(Pu var type = typeElement.GetString(); if (string.IsNullOrWhiteSpace(type) || - !type.StartsWith("PackageBaseAddress/", StringComparison.OrdinalIgnoreCase)) + !type!.StartsWith("PackageBaseAddress/", StringComparison.OrdinalIgnoreCase)) { continue; } @@ -360,9 +362,11 @@ private static Uri BuildFlatContainerPackageUri(Uri baseUri, NuGetPackageIdentit var metadata = xml.Root?.Elements().FirstOrDefault(element => element.Name.LocalName.Equals("metadata", StringComparison.OrdinalIgnoreCase)); var id = metadata?.Elements().FirstOrDefault(element => element.Name.LocalName.Equals("id", StringComparison.OrdinalIgnoreCase))?.Value; var version = metadata?.Elements().FirstOrDefault(element => element.Name.LocalName.Equals("version", StringComparison.OrdinalIgnoreCase))?.Value; - return string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(version) + var packageId = id; + var packageVersion = version; + return string.IsNullOrWhiteSpace(packageId) || string.IsNullOrWhiteSpace(packageVersion) ? null - : new NuGetPackageIdentity(id.Trim(), version.Trim()); + : new NuGetPackageIdentity(packageId!.Trim(), packageVersion!.Trim()); } catch { @@ -379,10 +383,31 @@ private static PublishVerificationResult Failed(string summary) private static PublishVerificationResult Skipped(string summary) => new() { Status = PublishVerificationStatus.Skipped, Summary = summary }; - private sealed record NuGetPackageIdentity(string Id, string Version); + private sealed class NuGetPackageIdentity + { + public NuGetPackageIdentity(string id, string version) + { + Id = id; + Version = version; + } + + public string Id { get; } + + public string Version { get; } + } - private readonly record struct ProbeResponse(bool Succeeded, HttpStatusCode? StatusCode) + private struct ProbeResponse { + public ProbeResponse(bool succeeded, HttpStatusCode? statusCode) + { + Succeeded = succeeded; + StatusCode = statusCode; + } + + public bool Succeeded { get; } + + public HttpStatusCode? StatusCode { get; } + public static ProbeResponse Failed => new(false, null); } } diff --git a/PowerForge/Services/ReleaseSigningHostSettingsResolver.cs b/PowerForge/Services/ReleaseSigningHostSettingsResolver.cs index 55a1c79b..620e2655 100644 --- a/PowerForge/Services/ReleaseSigningHostSettingsResolver.cs +++ b/PowerForge/Services/ReleaseSigningHostSettingsResolver.cs @@ -48,27 +48,29 @@ public ReleaseSigningHostSettings Resolve() if (string.IsNullOrWhiteSpace(thumbprint)) { + var unresolvedModulePath = modulePath ?? string.Empty; return new ReleaseSigningHostSettings { IsConfigured = false, StoreName = storeName, TimeStampServer = timeStampServer, - ModulePath = modulePath, + ModulePath = unresolvedModulePath, MissingConfigurationMessage = "Signing is not configured. Set RELEASE_OPS_STUDIO_SIGN_THUMBPRINT first." }; } + var resolvedModulePath = modulePath ?? string.Empty; return new ReleaseSigningHostSettings { IsConfigured = true, Thumbprint = thumbprint, StoreName = storeName, TimeStampServer = timeStampServer, - ModulePath = modulePath + ModulePath = resolvedModulePath }; } private static string? TrimOrNull(string? value) - => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + => string.IsNullOrWhiteSpace(value) ? null : value!.Trim(); private static string TrimOrDefault(string? value, string defaultValue) - => string.IsNullOrWhiteSpace(value) ? defaultValue : value.Trim(); + => string.IsNullOrWhiteSpace(value) ? defaultValue : value!.Trim(); } diff --git a/PowerForge/Services/RunnerHousekeepingService.cs b/PowerForge/Services/RunnerHousekeepingService.cs index 1e3faa47..fab19aa5 100644 --- a/PowerForge/Services/RunnerHousekeepingService.cs +++ b/PowerForge/Services/RunnerHousekeepingService.cs @@ -515,7 +515,8 @@ private static string AppendDirectorySeparator(string path) if (string.IsNullOrEmpty(path)) return Path.DirectorySeparatorChar.ToString(); - return path.EndsWith(Path.DirectorySeparatorChar) || path.EndsWith(Path.AltDirectorySeparatorChar) + return path.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal) + || path.EndsWith(Path.AltDirectorySeparatorChar.ToString(), StringComparison.Ordinal) ? path : path + Path.DirectorySeparatorChar; }