From 7ee75a51ae009747bf2a5f60e9d10aa371ea8a07 Mon Sep 17 00:00:00 2001 From: Santiago Squarzon Date: Mon, 24 Jun 2024 10:34:38 -0300 Subject: [PATCH] updates build --- .gitignore | 5 + .vscode/launch.json | 1 + PSTree.build.ps1 | 252 --------------------- ScriptAnalyzerSettings.psd1 | 29 --- build.ps1 | 95 ++++---- tools/InvokeBuild.ps1 | 97 ++++++++ tools/PesterTest.ps1 | 59 ++--- tools/ProjectBuilder/Documentation.cs | 12 + tools/ProjectBuilder/Extensions.cs | 30 +++ tools/ProjectBuilder/Module.cs | 163 +++++++++++++ tools/ProjectBuilder/Pester.cs | 115 ++++++++++ tools/ProjectBuilder/Project.cs | 73 ++++++ tools/ProjectBuilder/ProjectBuilder.csproj | 14 ++ tools/ProjectBuilder/ProjectInfo.cs | 180 +++++++++++++++ tools/ProjectBuilder/Types.cs | 11 + tools/prompt.ps1 | 1 + 16 files changed, 755 insertions(+), 382 deletions(-) delete mode 100644 PSTree.build.ps1 delete mode 100644 ScriptAnalyzerSettings.psd1 create mode 100644 tools/InvokeBuild.ps1 create mode 100644 tools/ProjectBuilder/Documentation.cs create mode 100644 tools/ProjectBuilder/Extensions.cs create mode 100644 tools/ProjectBuilder/Module.cs create mode 100644 tools/ProjectBuilder/Pester.cs create mode 100644 tools/ProjectBuilder/Project.cs create mode 100644 tools/ProjectBuilder/ProjectBuilder.csproj create mode 100644 tools/ProjectBuilder/ProjectInfo.cs create mode 100644 tools/ProjectBuilder/Types.cs create mode 100644 tools/prompt.ps1 diff --git a/.gitignore b/.gitignore index 80a49e0..480bc3e 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ tools/dotnet *.user *.userosscache *.sln.docstates +*.zip # User-specific files (MonoDevelop/Xamarin Studio) *.userprefs @@ -270,3 +271,7 @@ tools/Modules test.settings.json tests/integration/.vagrant tests/integration/cert_setup +tools/ProjectBuilder/output +tools/ProjectBuilder/bin +tools/ProjectBuilder/debug +tools/ProjectBuilder/obj diff --git a/.vscode/launch.json b/.vscode/launch.json index 09871af..c832652 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -13,6 +13,7 @@ "-NoExit", "-NoProfile", "-Command", + ". ./tools/prompt.ps1;", "Import-Module ./output/PSTree" ], "cwd": "${workspaceFolder}", diff --git a/PSTree.build.ps1 b/PSTree.build.ps1 deleted file mode 100644 index 9719668..0000000 --- a/PSTree.build.ps1 +++ /dev/null @@ -1,252 +0,0 @@ -# I might've also stolen this from jborean93 ¯\_(ツ)_/¯ -[CmdletBinding()] -param( - [ValidateSet('Debug', 'Release')] - [string] - $Configuration = 'Debug' -) - -$modulePath = [IO.Path]::Combine($PSScriptRoot, 'module') -$manifestItem = Get-Item ([IO.Path]::Combine($modulePath, '*.psd1')) -$ModuleName = $manifestItem.BaseName - -$testModuleManifestSplat = @{ - Path = $manifestItem.FullName - ErrorAction = 'Ignore' - WarningAction = 'Ignore' -} -$Manifest = Test-ModuleManifest @testModuleManifestSplat -$Version = $Manifest.Version -$BuildPath = [IO.Path]::Combine($PSScriptRoot, 'output') -$PowerShellPath = [IO.Path]::Combine($PSScriptRoot, 'module') -$CSharpPath = [IO.Path]::Combine($PSScriptRoot, 'src', $ModuleName) -$ReleasePath = [IO.Path]::Combine($BuildPath, $ModuleName, $Version) -$IsUnix = $PSEdition -eq 'Core' -and -not $IsWindows -$UseNativeArguments = $PSVersionTable.PSVersion -gt '7.0' -($csharpProjectInfo = [xml]::new()).Load((Get-Item ([IO.Path]::Combine($CSharpPath, '*.csproj'))).FullName) -$TargetFrameworks = @(@($csharpProjectInfo.Project.PropertyGroup)[0]. - TargetFrameworks.Split(';', [StringSplitOptions]::RemoveEmptyEntries)) - -$PSFramework = $TargetFrameworks[0] - -[xml] $csharpProjectInfo = Get-Content ([IO.Path]::Combine($CSharpPath, '*.csproj')) -$TargetFrameworks = @(@($csharpProjectInfo.Project.PropertyGroup)[0].TargetFrameworks.Split( - ';', [StringSplitOptions]::RemoveEmptyEntries)) -$PSFramework = $TargetFrameworks[0] - -task Clean { - if (Test-Path $ReleasePath) { - Remove-Item $ReleasePath -Recurse -Force - } - - New-Item -ItemType Directory $ReleasePath | Out-Null -} - -task BuildDocs { - $helpParams = @{ - Path = [IO.Path]::Combine($PSScriptRoot, 'docs', 'en-US') - OutputPath = [IO.Path]::Combine($ReleasePath, 'en-US') - } - New-ExternalHelp @helpParams | Out-Null -} - -task BuildManaged { - $arguments = @( - 'publish' - '--configuration', $Configuration - '--verbosity', 'q' - '-nologo' - "-p:Version=$Version" - ) - - Push-Location -LiteralPath $CSharpPath - try { - foreach ($framework in $TargetFrameworks) { - Write-Host "Compiling for $framework" - dotnet @arguments --framework $framework - - if ($LASTEXITCODE) { - throw "Failed to compiled code for $framework" - } - } - } - finally { - Pop-Location - } -} - -task CopyToRelease { - $copyParams = @{ - Path = [IO.Path]::Combine($PowerShellPath, '*') - Destination = $ReleasePath - Recurse = $true - Force = $true - } - Copy-Item @copyParams - - foreach ($framework in $TargetFrameworks) { - $buildFolder = [IO.Path]::Combine($CSharpPath, 'bin', $Configuration, $framework, 'publish') - $binFolder = [IO.Path]::Combine($ReleasePath, 'bin', $framework, $_.Name) - if (-not (Test-Path -LiteralPath $binFolder)) { - New-Item -Path $binFolder -ItemType Directory | Out-Null - } - Copy-Item ([IO.Path]::Combine($buildFolder, '*')) -Destination $binFolder -Recurse - } -} - -task Package { - $nupkgPath = [IO.Path]::Combine($BuildPath, "$ModuleName.$Version*.nupkg") - if (Test-Path $nupkgPath) { - Remove-Item $nupkgPath -Force - } - - $repoParams = @{ - Name = 'LocalRepo' - SourceLocation = $BuildPath - PublishLocation = $BuildPath - InstallationPolicy = 'Trusted' - } - if (Get-PSRepository -Name $repoParams.Name -ErrorAction SilentlyContinue) { - Unregister-PSRepository -Name $repoParams.Name - } - - Register-PSRepository @repoParams - try { - Publish-Module -Path $ReleasePath -Repository $repoParams.Name - } - finally { - Unregister-PSRepository -Name $repoParams.Name - } -} - -task Analyze { - $pssaSplat = @{ - Path = $ReleasePath - Settings = [IO.Path]::Combine($PSScriptRoot, 'ScriptAnalyzerSettings.psd1') - Recurse = $true - ErrorAction = 'SilentlyContinue' - } - $results = Invoke-ScriptAnalyzer @pssaSplat - if ($null -ne $results) { - $results | Out-String - throw 'Failed PsScriptAnalyzer tests, build failed' - } -} - -task DoUnitTest { - $testsPath = [IO.Path]::Combine($PSScriptRoot, 'tests', 'units') - if (-not (Test-Path -LiteralPath $testsPath)) { - Write-Host 'No unit tests found, skipping' - return - } - - $resultsPath = [IO.Path]::Combine($BuildPath, 'TestResults') - if (-not (Test-Path -LiteralPath $resultsPath)) { - New-Item $resultsPath -ItemType Directory -ErrorAction Stop | Out-Null - } - - $tempResultsPath = [IO.Path]::Combine($resultsPath, 'TempUnit') - if (Test-Path -LiteralPath $tempResultsPath) { - Remove-Item -LiteralPath $tempResultsPath -Force -Recurse - } - New-Item -Path $tempResultsPath -ItemType Directory | Out-Null - - try { - $runSettingsPrefix = 'DataCollectionRunSettings.DataCollectors.DataCollector.Configuration' - $arguments = @( - 'test' - $testsPath - '--results-directory', $tempResultsPath - if ($Configuration -eq 'Debug') { - '--collect:"XPlat Code Coverage"' - '--' - "$runSettingsPrefix.Format=json" - if ($UseNativeArguments) { - "$runSettingsPrefix.IncludeDirectory=`"$CSharpPath`"" - } - else { - "$runSettingsPrefix.IncludeDirectory=\`"$CSharpPath\`"" - } - } - ) - - Write-Host 'Running unit tests' - dotnet @arguments - - if ($LASTEXITCODE) { - throw 'Unit tests failed' - } - - if ($Configuration -eq 'Debug') { - Move-Item -Path $tempResultsPath/*/*.json -Destination $resultsPath/UnitCoverage.json -Force - } - } - finally { - Remove-Item -LiteralPath $tempResultsPath -Force -Recurse - } -} - -task DoTest { - $pesterScript = [IO.Path]::Combine($PSScriptRoot, 'tools', 'PesterTest.ps1') - if (-not (Test-Path $pesterScript)) { - Write-Host 'No Pester tests found, skipping' - return - } - - $resultsPath = [IO.Path]::Combine($BuildPath, 'TestResults') - if (-not (Test-Path $resultsPath)) { - New-Item $resultsPath -ItemType Directory -ErrorAction Stop | Out-Null - } - - $resultsFile = [IO.Path]::Combine($resultsPath, 'Pester.xml') - if (Test-Path $resultsFile) { - Remove-Item $resultsFile -ErrorAction Stop -Force - } - - $pwsh = [Environment]::GetCommandLineArgs()[0] -replace '\.dll$', '' - $arguments = @( - '-NoProfile' - '-NonInteractive' - if (-not $IsUnix) { - '-ExecutionPolicy', 'Bypass' - } - '-File', $pesterScript - '-TestPath', ([IO.Path]::Combine($PSScriptRoot, 'tests')) - '-OutputFile', $resultsFile - ) - - if ($Configuration -eq 'Debug') { - $unitCoveragePath = [IO.Path]::Combine($resultsPath, 'UnitCoverage.json') - $targetArgs = '"' + ($arguments -join '" "') + '"' - - if ($UseNativeArguments) { - $watchFolder = [IO.Path]::Combine($ReleasePath, 'bin', $PSFramework) - } - else { - $targetArgs = '"' + ($targetArgs -replace '"', '\"') + '"' - $watchFolder = '"{0}"' -f ([IO.Path]::Combine($ReleasePath, 'bin', $PSFramework)) - } - - $arguments = @( - $watchFolder - '--target', $pwsh - '--targetargs', $targetArgs - '--output', ([IO.Path]::Combine($resultsPath, 'Coverage.xml')) - '--format', 'cobertura' - if (Test-Path -LiteralPath $unitCoveragePath) { - '--merge-with', $unitCoveragePath - } - ) - $pwsh = 'coverlet' - } - - & $pwsh $arguments - - if ($LASTEXITCODE) { - throw 'Pester failed tests' - } -} - -task Build -Jobs Clean, BuildManaged, CopyToRelease, BuildDocs, Package -task Test -Jobs BuildManaged, Analyze, DoUnitTest, DoTest -task . Build diff --git a/ScriptAnalyzerSettings.psd1 b/ScriptAnalyzerSettings.psd1 deleted file mode 100644 index 882e626..0000000 --- a/ScriptAnalyzerSettings.psd1 +++ /dev/null @@ -1,29 +0,0 @@ -# The PowerShell Script Analyzer will generate a warning -# diagnostic record for this file due to a bug - -# https://github.com/PowerShell/PSScriptAnalyzer/issues/472 -@{ - # Only diagnostic records of the specified severity will be generated. - # Uncomment the following line if you only want Errors and Warnings but - # not Information diagnostic records. - - # Severity = @('Error','Warning') - - # Analyze **only** the following rules. Use IncludeRules when you want - # to invoke only a small subset of the defualt rules. - - # IncludeRules = @('PSAvoidDefaultValueSwitchParameter', - # 'PSMissingModuleManifestField', - # 'PSReservedCmdletChar', - # 'PSReservedParams', - # 'PSShouldProcess', - # 'PSUseApprovedVerbs', - # 'PSUseDeclaredVarsMoreThanAssigments') - - # Do not analyze the following rules. Use ExcludeRules when you have - # commented out the IncludeRules settings above and want to include all - # the default rules except for those you exclude below. - # Note: if a rule is in both IncludeRules and ExcludeRules, the rule - # will be excluded. - - # ExcludeRules = @('') -} diff --git a/build.ps1 b/build.ps1 index 117cdb4..650b40b 100644 --- a/build.ps1 +++ b/build.ps1 @@ -1,70 +1,51 @@ -# I may've totally stolen this from jborean93 :D -[CmdletBinding()] +[CmdletBinding()] param( [Parameter()] [ValidateSet('Debug', 'Release')] - [string] - $Configuration = 'Debug', + [string] $Configuration = 'Debug', [Parameter()] - [string[]] - $Task = 'Build' + [ValidateSet('Build', 'Test')] + [string[]] $Task = 'Build' ) -end { - if ($PSEdition -eq 'Desktop') { - [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor 'Tls12' - } - - $modulePath = [IO.Path]::Combine($PSScriptRoot, 'tools', 'Modules') - $requirements = Import-PowerShellDataFile ([IO.Path]::Combine($PSScriptRoot, 'tools', 'requiredModules.psd1')) - - foreach ($req in $requirements.GetEnumerator()) { - $targetPath = [IO.Path]::Combine($modulePath, $req.Key) - - if (Test-Path -LiteralPath $targetPath) { - Import-Module -Name $targetPath -Force -ErrorAction Stop - continue - } - - Write-Host "Installing build pre-req $($req.Key) as it is not installed" - $null = New-Item -Path $targetPath -ItemType Directory -Force - - $webParams = @{ - Uri = "https://www.powershellgallery.com/api/v2/package/$($req.Key)/$($req.Value)" - OutFile = [IO.Path]::Combine($modulePath, "$($req.Key).zip") - UseBasicParsing = $true - } - - if ('Authentication' -in (Get-Command -Name Invoke-WebRequest).Parameters.Keys) { - $webParams.Authentication = 'None' +$prev = $ErrorActionPreference +$ErrorActionPreference = 'Stop' + +if (-not ('ProjectBuilder.ProjectInfo' -as [type])) { + try { + $builderPath = [IO.Path]::Combine($PSScriptRoot, 'tools', 'ProjectBuilder') + Push-Location $builderPath + + dotnet @( + 'publish' + '--configuration', 'Release' + '-o', 'output' + '--framework', 'netstandard2.0' + '--verbosity', 'q' + '-nologo' + ) + + if ($LASTEXITCODE) { + throw "Failed to compiled 'ProjectBuilder'" } - $oldProgress = $ProgressPreference - $ProgressPreference = 'SilentlyContinue' - - try { - Invoke-WebRequest @webParams - Expand-Archive -Path $webParams.OutFile -DestinationPath $targetPath -Force - Remove-Item -LiteralPath $webParams.OutFile -Force - } - finally { - $ProgressPreference = $oldProgress - } - - Import-Module -Name $targetPath -Force -ErrorAction Stop + $dll = [IO.Path]::Combine($builderPath, 'output', 'ProjectBuilder.dll') + Add-Type -Path $dll } - - $dotnetTools = @(dotnet tool list --global) -join "`n" - if (-not $dotnetTools.Contains('coverlet.console')) { - Write-Host 'Installing dotnet tool coverlet.console' - dotnet tool install --global coverlet.console --version 6.0.0 + finally { + Pop-Location } +} - $invokeBuildSplat = @{ - Task = $Task - File = (Get-Item ([IO.Path]::Combine($PSScriptRoot, '*.build.ps1'))).FullName - Configuration = $Configuration - } - Invoke-Build @invokeBuildSplat +$projectInfo = [ProjectBuilder.ProjectInfo]::Create($PSScriptRoot, $Configuration) +$projectInfo.GetRequirements() | Import-Module -DisableNameChecking -Force + +$ErrorActionPreference = $prev + +$invokeBuildSplat = @{ + Task = $Task + File = Convert-Path ([IO.Path]::Combine($PSScriptRoot, 'tools', 'InvokeBuild.ps1')) + ProjectInfo = $projectInfo } +Invoke-Build @invokeBuildSplat diff --git a/tools/InvokeBuild.ps1 b/tools/InvokeBuild.ps1 new file mode 100644 index 0000000..4ece54d --- /dev/null +++ b/tools/InvokeBuild.ps1 @@ -0,0 +1,97 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [ProjectBuilder.ProjectInfo] $ProjectInfo +) + +task Clean { + $ProjectInfo.CleanRelease() +} + +task BuildDocs { + $helpParams = $ProjectInfo.Documentation.GetParams() + $null = New-ExternalHelp @helpParams +} + +task BuildManaged { + $arguments = $ProjectInfo.GetBuildArgs() + Push-Location -LiteralPath $ProjectInfo.Project.Source.FullName + + try { + foreach ($framework in $ProjectInfo.Project.TargetFrameworks) { + Write-Host "Compiling for $framework" + dotnet @arguments --framework $framework + + if ($LASTEXITCODE) { + throw "Failed to compiled code for $framework" + } + } + } + finally { + Pop-Location + } +} + +task CopyToRelease { + $ProjectInfo.Module.CopyToRelease() + $ProjectInfo.Project.CopyToRelease() +} + +task Package { + $ProjectInfo.Project.ClearNugetPackage() + $repoParams = $ProjectInfo.Project.GetPSRepoParams() + + if (Get-PSRepository -Name $repoParams.Name -ErrorAction SilentlyContinue) { + Unregister-PSRepository -Name $repoParams.Name + } + + Register-PSRepository @repoParams + try { + $publishModuleSplat = @{ + Path = $ProjectInfo.Project.Release + Repository = $repoParams.Name + } + Publish-Module @publishModuleSplat + } + finally { + Unregister-PSRepository -Name $repoParams.Name + } +} + +task Analyze { + if (-not $ProjectInfo.AnalyzerPath) { + Write-Host 'No Analyzer Settings found, skipping' + return + } + + $pssaSplat = $ProjectInfo.GetAnalyzerParams() + $results = Invoke-ScriptAnalyzer @pssaSplat + + if ($results) { + $results | Out-String + throw 'Failed PsScriptAnalyzer tests, build failed' + } +} + +task PesterTests { + if (-not $ProjectInfo.Pester.PesterScript) { + Write-Host 'No Pester tests found, skipping' + return + } + + $ProjectInfo.Pester.ClearResultFile() + + if (-not (dotnet tool list --global | Select-String coverlet.console -SimpleMatch)) { + Write-Host 'Installing dotnet tool coverlet.console' -ForegroundColor Yellow + dotnet tool install --global coverlet.console + } + + coverlet $ProjectInfo.Pester.GetTestArgs($PSVersionTable.PSVersion) + + if ($LASTEXITCODE) { + throw 'Pester failed tests' + } +} + +task Build -Jobs Clean, BuildManaged, CopyToRelease, BuildDocs, Package +task Test -Jobs BuildManaged, Analyze, PesterTests diff --git a/tools/PesterTest.ps1 b/tools/PesterTest.ps1 index 92c4a15..1497703 100644 --- a/tools/PesterTest.ps1 +++ b/tools/PesterTest.ps1 @@ -1,61 +1,32 @@ -<# -.SYNOPSIS -Run Pester test - -.PARAMETER TestPath -The path to the tests to run - -.PARAMETER OutputFile -The path to write the Pester test results to. -#> -[CmdletBinding()] +[CmdletBinding()] param ( [Parameter(Mandatory)] - [String] - $TestPath, + [String] $TestPath, [Parameter(Mandatory)] - [String] - $OutputFile + [String] $OutputFile ) $ErrorActionPreference = 'Stop' -$requirements = Import-PowerShellDataFile ([IO.Path]::Combine($PSScriptRoot, 'requiredModules.psd1')) -foreach ($req in $requirements.GetEnumerator()) { - $importModuleSplat = @{ - Name = ([IO.Path]::Combine($PSScriptRoot, 'Modules', $req.Key)) - Force = $true - DisableNameChecking = $true - } - Write-Host "Importing: $($importModuleSplat['Name'])" - Import-Module @importModuleSplat -} +Get-ChildItem ([IO.Path]::Combine($PSScriptRoot, 'Modules')) -Directory | + Import-Module -Name { $_.FullName } -Force -DisableNameChecking -[PSCustomObject] $PSVersionTable | - Select-Object -Property *, @{ - Name = 'Architecture' - Expression = { - switch ([IntPtr]::Size) { - 4 { - 'x86' - } - 8 { - 'x64' - } - default { - 'Unknown' - } - } +[PSCustomObject] $PSVersionTable | Select-Object *, @{ + Name = 'Architecture' + Expression = { + switch ([IntPtr]::Size) { + 4 { 'x86' } + 8 { 'x64' } + default { 'Unknown' } } - } | - Format-List | - Out-Host + } +} | Format-List | Out-Host $configuration = [PesterConfiguration]::Default $configuration.Output.Verbosity = 'Detailed' -$configuration.Run.Exit = $true $configuration.Run.Path = $TestPath +$configuration.Run.Throw = $true $configuration.TestResult.Enabled = $true $configuration.TestResult.OutputPath = $OutputFile $configuration.TestResult.OutputFormat = 'NUnitXml' diff --git a/tools/ProjectBuilder/Documentation.cs b/tools/ProjectBuilder/Documentation.cs new file mode 100644 index 0000000..1ba3e01 --- /dev/null +++ b/tools/ProjectBuilder/Documentation.cs @@ -0,0 +1,12 @@ +using System.Collections; + +namespace ProjectBuilder; + +public record struct Documentation(string Source, string Output) +{ + public readonly Hashtable GetParams() => new() + { + ["Path"] = Source, + ["OutputPath"] = Output + }; +} diff --git a/tools/ProjectBuilder/Extensions.cs b/tools/ProjectBuilder/Extensions.cs new file mode 100644 index 0000000..cc2c0fe --- /dev/null +++ b/tools/ProjectBuilder/Extensions.cs @@ -0,0 +1,30 @@ +using System; +using System.IO; + +namespace ProjectBuilder; + +internal static class Extensions +{ + internal static void CopyRecursive(this DirectoryInfo source, string? destination) + { + if (destination is null) + { + throw new ArgumentNullException($"Destination path is null.", nameof(destination)); + } + + if (!Directory.Exists(destination)) + { + Directory.CreateDirectory(destination); + } + + foreach (DirectoryInfo dir in source.EnumerateDirectories("*", SearchOption.AllDirectories)) + { + Directory.CreateDirectory(dir.FullName.Replace(source.FullName, destination)); + } + + foreach (FileInfo file in source.EnumerateFiles("*", SearchOption.AllDirectories)) + { + file.CopyTo(file.FullName.Replace(source.FullName, destination)); + } + } +} diff --git a/tools/ProjectBuilder/Module.cs b/tools/ProjectBuilder/Module.cs new file mode 100644 index 0000000..a2ff7cf --- /dev/null +++ b/tools/ProjectBuilder/Module.cs @@ -0,0 +1,163 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Management.Automation; +using System.Net.Http; +using System.Threading.Tasks; + +namespace ProjectBuilder; + +public sealed class Module +{ + public DirectoryInfo Root { get; } + + public FileInfo? Manifest { get; internal set; } + + public Version? Version { get; internal set; } + + public string Name { get; } + + public string PreRequisitePath { get; } + + private string? Release { get => _info.Project.Release; } + + private readonly UriBuilder _builder = new(_base); + + private const string _base = "https://www.powershellgallery.com"; + + private const string _path = "api/v2/package/{0}/{1}"; + + private readonly ProjectInfo _info; + + private Hashtable? _req; + + internal Module( + DirectoryInfo directory, + string name, + ProjectInfo info) + { + Root = directory; + Name = name; + PreRequisitePath = InitPrerequisitePath(Root); + _info = info; + } + + public void CopyToRelease() => Root.CopyRecursive(Release); + + internal IEnumerable GetRequirements(string path) + { + _req ??= ImportRequirements(path); + + if (_req is { Count: 0 }) + { + return []; + } + + List modules = new(_req.Count); + foreach (DictionaryEntry entry in _req) + { + modules.Add(new ModuleDownload + { + Module = entry.Key.ToString(), + Version = LanguagePrimitives.ConvertTo(entry.Value) + }); + } + + return DownloadModules([.. modules]); + } + + private static Hashtable ImportRequirements(string path) + { + using PowerShell powerShell = PowerShell.Create(RunspaceMode.CurrentRunspace); + return powerShell + .AddCommand("Import-PowerShellDataFile") + .AddArgument(path) + .Invoke() + .FirstOrDefault(); + } + + private string[] DownloadModules(ModuleDownload[] modules) + { + List> tasks = new(modules.Length); + List output = new(modules.Length); + + foreach ((string module, Version version) in modules) + { + string destination = GetDestination(module); + string modulePath = GetModulePath(module); + + if (Directory.Exists(modulePath)) + { + output.Add(modulePath); + continue; + } + + Console.WriteLine($"Installing build pre-req '{module}'"); + _builder.Path = string.Format(_path, module, version); + Task task = GetModuleAsync( + uri: _builder.Uri.ToString(), + destination: destination, + expandPath: modulePath); + tasks.Add(task); + } + + output.AddRange(WaitTask(tasks)); + return [.. output]; + } + + private static string[] WaitTask(List> tasks) => + WaitTaskAsync(tasks).GetAwaiter().GetResult(); + + private static void ExpandArchive(string source, string destination) => + ZipFile.ExtractToDirectory(source, destination); + + private static async Task WaitTaskAsync( + List> tasks) + { + List completedTasks = new(tasks.Count); + while (tasks.Count > 0) + { + Task awaiter = await Task.WhenAny(tasks); + tasks.Remove(awaiter); + string module = await awaiter; + completedTasks.Add(module); + } + return [.. completedTasks]; + } + + private string GetDestination(string module) => + Path.Combine(PreRequisitePath, Path.ChangeExtension(module, "zip")); + + private string GetModulePath(string module) => + Path.Combine(PreRequisitePath, module); + + private static async Task GetModuleAsync( + string uri, + string destination, + string expandPath) + { + using (FileStream fs = File.Create(destination)) + { + using HttpClient client = new(); + using Stream stream = await client.GetStreamAsync(uri); + await stream.CopyToAsync(fs); + } + + ExpandArchive(destination, expandPath); + File.Delete(destination); + return expandPath; + } + + private static string InitPrerequisitePath(DirectoryInfo root) + { + string path = Path.Combine(root.Parent.FullName, "tools", "Modules"); + if (!Directory.Exists(path)) + { + Directory.CreateDirectory(path); + } + return path; + } +} diff --git a/tools/ProjectBuilder/Pester.cs b/tools/ProjectBuilder/Pester.cs new file mode 100644 index 0000000..fccaac1 --- /dev/null +++ b/tools/ProjectBuilder/Pester.cs @@ -0,0 +1,115 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Text.RegularExpressions; + +namespace ProjectBuilder; + +public sealed class Pester +{ + public string? PesterScript + { + get + { + _pesterPath ??= Path.Combine( + _info.Root.FullName, + "tools", + "PesterTest.ps1"); + + if (_testsExist = File.Exists(_pesterPath)) + { + return _pesterPath; + } + + return null; + } + } + + public string? ResultPath { get => _testsExist ? Path.Combine(_info.Project.Build, "TestResults") : null; } + + public string? ResultFile { get => _testsExist ? Path.Combine(ResultPath, "Pester.xml") : null; } + + private readonly ProjectInfo _info; + + private string? _pesterPath; + + private bool _testsExist; + + internal Pester(ProjectInfo info) => _info = info; + + private void CreateResultPath() + { + if (!Directory.Exists(ResultPath)) + { + Directory.CreateDirectory(ResultPath); + } + } + + public void ClearResultFile() + { + if (File.Exists(ResultFile)) + { + File.Delete(ResultFile); + } + } + + public string[] GetTestArgs(Version version) + { + CreateResultPath(); + + List arguments = [ + "-NoProfile", + "-NonInteractive", + ]; + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + arguments.AddRange([ "-ExecutionPolicy", "Bypass" ]); + } + + arguments.AddRange([ + "-File", PesterScript!, + "-TestPath", Path.Combine(_info.Root.FullName, "tests"), + "-OutputFile", ResultFile! + ]); + + Regex re = new("^|$", RegexOptions.Compiled); + string targetArgs = re.Replace(string.Join("\" \"", [.. arguments]), "\""); + string pwsh = Regex.Replace(Environment.GetCommandLineArgs().First(), @"\.dll$", string.Empty); + string unitCoveragePath = Path.Combine(ResultPath, "UnitCoverage.json"); + string watchFolder = Path.Combine(_info.Project.Release, "bin", _info.Project.TestFramework); + string sourceMappingFile = Path.Combine(ResultPath, "CoverageSourceMapping.txt"); + + if (version is not { Major: >= 7, Minor: > 0 }) + { + targetArgs = re.Replace(targetArgs, "\""); + watchFolder = re.Replace(watchFolder, "\""); + } + + arguments.Clear(); + arguments.AddRange([ + watchFolder, + "--target", pwsh, + "--targetargs", targetArgs, + "--output", Path.Combine(ResultPath, "Coverage.xml"), + "--format", "cobertura" + ]); + + if (File.Exists(unitCoveragePath)) + { + arguments.AddRange([ "--merge-with", unitCoveragePath ]); + } + + if (Environment.GetEnvironmentVariable("GITHUB_ACTIONS") is "true") + { + arguments.AddRange([ "--source-mapping-file", sourceMappingFile ]); + File.WriteAllText( + sourceMappingFile, + $"|{_info.Root.FullName}{Path.DirectorySeparatorChar}=/_/"); + } + + return [.. arguments]; + } +} diff --git a/tools/ProjectBuilder/Project.cs b/tools/ProjectBuilder/Project.cs new file mode 100644 index 0000000..831c292 --- /dev/null +++ b/tools/ProjectBuilder/Project.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections; +using System.IO; +using System.Linq; + +namespace ProjectBuilder; + +public sealed class Project +{ + public DirectoryInfo Source { get; } + + public string Build { get; } + + public string? Release { get; internal set; } + + public string[]? TargetFrameworks { get; internal set; } + + public string? TestFramework { get => TargetFrameworks.FirstOrDefault(); } + + private Configuration Configuration { get => _info.Configuration; } + + private readonly ProjectInfo _info; + + internal Project(DirectoryInfo source, string build, ProjectInfo info) + { + Source = source; + Build = build; + _info = info; + } + + public void CopyToRelease() + { + if (TargetFrameworks is null) + { + throw new ArgumentNullException( + "TargetFrameworks is null.", + nameof(TargetFrameworks)); + } + + foreach (string framework in TargetFrameworks) + { + DirectoryInfo buildFolder = new(Path.Combine( + Source.FullName, + "bin", + Configuration.ToString(), + framework, + "publish")); + + string binFolder = Path.Combine(Release, "bin", framework); + buildFolder.CopyRecursive(binFolder); + } + } + + public Hashtable GetPSRepoParams() => new() + { + ["Name"] = "LocalRepo", + ["SourceLocation"] = Build, + ["PublishLocation"] = Build, + ["InstallationPolicy"] = "Trusted" + }; + + public void ClearNugetPackage() + { + string nugetPath = Path.Combine( + Build, + $"{_info.Module.Name}.{_info.Module.Version}.nupkg"); + + if (File.Exists(nugetPath)) + { + File.Delete(nugetPath); + } + } +} diff --git a/tools/ProjectBuilder/ProjectBuilder.csproj b/tools/ProjectBuilder/ProjectBuilder.csproj new file mode 100644 index 0000000..6a1a2a5 --- /dev/null +++ b/tools/ProjectBuilder/ProjectBuilder.csproj @@ -0,0 +1,14 @@ + + + + netstandard2.0 + enable + latest + ProjectBuilder + + + + + + + diff --git a/tools/ProjectBuilder/ProjectInfo.cs b/tools/ProjectBuilder/ProjectInfo.cs new file mode 100644 index 0000000..b1f7b2c --- /dev/null +++ b/tools/ProjectBuilder/ProjectInfo.cs @@ -0,0 +1,180 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Management.Automation; +using System.Xml; + +namespace ProjectBuilder; + +public sealed class ProjectInfo +{ + public DirectoryInfo Root { get; } + + public Module Module { get; } + + public Configuration Configuration { get; internal set; } + + public Documentation Documentation { get; internal set; } + + public Project Project { get; } + + public Pester Pester { get; } + + public string? AnalyzerPath + { + get + { + _analyzerPath ??= Path.Combine( + Root.FullName, + "ScriptAnalyzerSettings.psd1"); + + if (File.Exists(_analyzerPath)) + { + return _analyzerPath; + } + + return null; + } + } + + private string? _analyzerPath; + + private ProjectInfo(string path) + { + Root = AssertDirectory(path); + + Module = new Module( + directory: AssertDirectory(GetModulePath(path)), + name: Path.GetFileNameWithoutExtension(path), + info: this); + + Project = new Project( + source: AssertDirectory(GetSourcePath(path, Module.Name)), + build: GetBuildPath(path), + info: this); + + Pester = new(this); + } + + public static ProjectInfo Create( + string path, + Configuration configuration) + { + ProjectInfo builder = new(path) + { + Configuration = configuration + }; + builder.Module.Manifest = GetManifest(builder); + builder.Module.Version = GetManifestVersion(builder); + builder.Project.Release = GetReleasePath( + builder.Project.Build, + builder.Module.Name, + builder.Module.Version!); + builder.Project.TargetFrameworks = GetTargetFrameworks(GetProjectFile(builder)); + builder.Documentation = new Documentation + { + Source = Path.Combine(builder.Root.FullName, "docs", "en-US"), + Output = Path.Combine(builder.Project.Release, "en-US") + }; + + return builder; + } + + public IEnumerable GetRequirements() + { + string req = Path.Combine(Root.FullName, "tools", "requiredModules.psd1"); + if (!File.Exists(req)) + { + return []; + } + return Module.GetRequirements(req); + } + + public void CleanRelease() + { + if (Directory.Exists(Project.Release)) + { + Directory.Delete(Project.Release, recursive: true); + } + Directory.CreateDirectory(Project.Release); + } + + public string[] GetBuildArgs() => + [ + "publish", + "--configuration", Configuration.ToString(), + "--verbosity", "q", + "-nologo", + $"-p:Version={Module.Version}" + ]; + + public Hashtable GetAnalyzerParams() => new() + { + ["Path"] = Project.Release, + ["Settings"] = AnalyzerPath, + ["Recurse"] = true, + ["ErrorAction"] = "SilentlyContinue" + }; + + private static string[] GetTargetFrameworks(string path) + { + XmlDocument xmlDocument = new(); + xmlDocument.Load(path); + return xmlDocument + .SelectSingleNode("Project/PropertyGroup/TargetFrameworks") + .InnerText + .Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries); + } + + private static string GetBuildPath(string path) => + Path.Combine(path, "output"); + + private static string GetSourcePath(string path, string moduleName) => + Path.Combine(path, "src", moduleName); + + private static string GetModulePath(string path) => + Path.Combine(path, "module"); + + private static string GetReleasePath( + string buildPath, + string moduleName, + Version version) => Path.Combine( + buildPath, + moduleName, + LanguagePrimitives.ConvertTo(version)); + + private static DirectoryInfo AssertDirectory(string path) + { + DirectoryInfo directory = new(path); + return directory.Exists ? directory + : throw new ArgumentException( + $"Path '{path}' could not be found or is not a Directory.", + nameof(path)); + } + + private static FileInfo GetManifest(ProjectInfo builder) => + builder.Module.Root.EnumerateFiles("*.psd1").FirstOrDefault() + ?? throw new FileNotFoundException( + $"Manifest file could not be found in '{builder.Root.FullName}'"); + + private static string GetProjectFile(ProjectInfo builder) => + builder.Project.Source.EnumerateFiles("*.csproj").FirstOrDefault()?.FullName + ?? throw new FileNotFoundException( + $"Project file could not be found in ''{builder.Project.Source.FullName}'"); + + private static Version? GetManifestVersion(ProjectInfo builder) + { + using PowerShell powershell = PowerShell.Create(RunspaceMode.CurrentRunspace); + Hashtable? moduleInfo = powershell + .AddCommand("Import-PowerShellDataFile") + .AddArgument(builder.Module.Manifest?.FullName) + .Invoke() + .FirstOrDefault(); + + return powershell.HadErrors + ? throw powershell.Streams.Error.First().Exception + : LanguagePrimitives.ConvertTo(moduleInfo?["ModuleVersion"]); + } +} diff --git a/tools/ProjectBuilder/Types.cs b/tools/ProjectBuilder/Types.cs new file mode 100644 index 0000000..3106122 --- /dev/null +++ b/tools/ProjectBuilder/Types.cs @@ -0,0 +1,11 @@ +using System; + +namespace ProjectBuilder; + +public enum Configuration +{ + Debug, + Release +} + +internal record struct ModuleDownload(string Module, Version Version); diff --git a/tools/prompt.ps1 b/tools/prompt.ps1 new file mode 100644 index 0000000..483e08b --- /dev/null +++ b/tools/prompt.ps1 @@ -0,0 +1 @@ +function prompt { "PS $($PWD.Path -replace '.+(?=\\)', '..')$('>' * ($nestedPromptLevel + 1)) " }