diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8e9286f --- /dev/null +++ b/.gitignore @@ -0,0 +1,43 @@ +/output/ +/debug*.ps1 +/temp*.ps1 + +# Most of these heavily cannibalized by Travis Drake from https://gist.github.com/kmorcinek/2710267 + +# C# VS build detritus +/.vs/ +/*/[Bb]in/ +/*/[Oo]bj/ +**/packages/* + +# Except this, this needs to be checked in when present +!**/packages/build/ + +# More C# / Visual Studio detritus +*.suo +*.user +*.sln.docstates +*.psess +*.vsp +*.vspx +project.lock.json +_UpgradeReport_Files/ +UpgradeLog*.XML +UpgradeLog*.htm + +# SASS Compiler cache +.sass-cache + +# Mac OS stuff +.DS_Store* +Icon? + +# Windows image file caches +Thumbs.db +ehthumbs.db + +# Folder config file +Desktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ diff --git a/.vscode/settings.json b/.vscode/settings.json index 31de61a..0df401f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,28 @@ // Place your settings in this file to overwrite default and user settings. { - "powershell.codeFormatting.preset": "Allman" -} \ No newline at end of file + //-------- Editor configuration -------- + "editor.insertSpaces": true, + "editor.tabSize": 4, + + //-------- Files configuration -------- + "files.autoGuessEncoding": false, + "files.insertFinalNewline": true, + "files.trimTrailingWhitespace": true, + "search.exclude": { + "**/Tests/Data*": true + }, + + //-------- PowerShell configuration -------- + "powershell.codeFormatting.alignPropertyValuePairs": true, + "powershell.codeFormatting.preset": "Allman", + "powershell.scriptAnalysis.settingsPath": "./ScriptAnalyzerSettings.psd1", + + //-------- Language configuration -------- + "[json]": { + "editor.tabSize": 2 + }, + + "[xml]": { + "editor.tabSize": 2 + } +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..8845dfa --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,95 @@ +// Available variables which can be used inside of strings: +// ${workspaceRoot}: The root folder of the team +// ${file}: The current opened file +// ${relativeFile}: The current opened file relative to workspaceRoot +// ${fileBasename}: The current opened file's basename +// ${fileDirname}: The current opened file's dirname +// ${fileExtname}: The current opened file's extension +// ${cwd}: The current working directory of the spawned process +{ + "version": "2.0.0", + "windows": { + "options": { + "shell": { + "executable": "powershell.exe", + "args": [ "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command" ] + } + } + }, + "linux": { + "options": { + "shell": { + "executable": "/usr/bin/pwsh", + "args": [ "-NoProfile", "-Command" ] + } + } + }, + "osx": { + "options": { + "shell": { + "executable": "/usr/local/bin/pwsh", + "args": [ "-NoProfile", "-Command" ] + } + } + }, + "tasks": [ + { + "label": "Default", + "type": "shell", + "problemMatcher": [ "$msCompile" ], + "group": { + "kind": "build", + "isDefault": true + }, + "command": "Invoke-Build -Task Default -File './Module.build.ps1'" + }, + { + "label": "Analyze", + "type": "shell", + "problemMatcher": [ "$msCompile" ], + "command": "Invoke-Build -Task Analyze -File './Module.build.ps1'" + }, + { + "label": "Build", + "type": "shell", + "problemMatcher": [ "$msCompile" ], + "command": "Invoke-Build -Task Build -File './Module.build.ps1'" + }, + { + "label": "Clean", + "type": "shell", + "problemMatcher": [ "$msCompile" ], + "command": "Invoke-Build -Task Clean -File './Module.build.ps1'" + }, + { + "label": "Helpify", + "type": "shell", + "problemMatcher": [ "$msCompile" ], + "command": "Invoke-Build -Task Helpify -File './Module.build.ps1'" + }, + { + "label": "Install", + "type": "shell", + "problemMatcher": [ "$msCompile" ], + "command": "Invoke-Build -Task Install -File './Module.build.ps1'" + }, + { + "label": "Test", + "type": "shell", + "problemMatcher": [ "$msCompile" ], + "command": "Invoke-Build -Task Test -File './Module.build.ps1'" + }, + { + "label": "Uninstall", + "type": "shell", + "problemMatcher": [ "$msCompile" ], + "command": "Invoke-Build -Task Uninstall -File './Module.build.ps1'" + }, + { + "label": "?", + "type": "shell", + "problemMatcher": [], + "command": "Invoke-Build -Task ? -File './Module.build.ps1'" + } + ] +} diff --git a/BuildTasks/Analyze.Task.ps1 b/BuildTasks/Analyze.Task.ps1 new file mode 100644 index 0000000..73825e3 --- /dev/null +++ b/BuildTasks/Analyze.Task.ps1 @@ -0,0 +1,17 @@ +task Analyze { + $params = @{ + IncludeDefaultRules = $true + Path = $ManifestPath + Settings = "$BuildRoot\ScriptAnalyzerSettings.psd1" + Severity = 'Warning' + } + + "Analyzing $ManifestPath..." + $results = Invoke-ScriptAnalyzer @params + if ($results) + { + 'One or more PSScriptAnalyzer errors/warnings were found.' + 'Please investigate or add the required SuppressMessage attribute.' + $results | Format-Table -AutoSize + } +} diff --git a/BuildTasks/BuildManifest.Task.ps1 b/BuildTasks/BuildManifest.Task.ps1 new file mode 100644 index 0000000..343e0e3 --- /dev/null +++ b/BuildTasks/BuildManifest.Task.ps1 @@ -0,0 +1,18 @@ + +taskx BuildManifest @{ + Inputs = (Get-ChildItem -Path $Source -Recurse -File) + Outputs = $ManifestPath + Jobs = { + "Updating [$ManifestPath]..." + Copy-Item -Path "$Source\$ModuleName.psd1" -Destination $ManifestPath + + $functions = Get-ChildItem -Path "$ModuleName\Public\*.ps1" -ErrorAction 'Ignore' | + Where-Object 'Name' -notmatch 'Tests' + + if ($functions) + { + 'Setting FunctionsToExport...' + Set-ModuleFunctions -Name $ManifestPath -FunctionsToExport $functions.BaseName + } + } +} diff --git a/BuildTasks/BuildModule.Task.ps1 b/BuildTasks/BuildModule.Task.ps1 new file mode 100644 index 0000000..5b3247c --- /dev/null +++ b/BuildTasks/BuildModule.Task.ps1 @@ -0,0 +1,214 @@ + +# namespaces for Move-Statement +using namespace System.Collections.Generic +using namespace System.IO +using namespace System.Management.Automation + +function Import-ClassOrder +{ + [cmdletbinding()] + param($cName,$Map) + Write-Verbose "Checking on [$cName]" + if($Map.ContainsKey($cName) -and $Map[$cName].Imported -ne $true) + { + if($Map[$cName].Base) + { + Write-Verbose " Base class [$($Map[$cName].Base)]" + Import-ClassOrder $Map[$cName].Base $Map + } + $cPath = $Map[$cName].Path + Write-Verbose "Dot Sourcing [$cPath]" + $cPath + $Map[$cName].Imported = $true + } +} + +# Temporarily added this here to be refactored/replaced by LDModuleBuilder Module + + +function Move-Statement +{ +<# +.SYNOPSIS + Moves statements containing a specified token to the specified index in a file. +.DESCRIPTION + Move-Statement moves statements containing a specified token, to the specified index + in a file. This can be used when building a module to move any using directives and + #Requires statements to the top of a file. +.PARAMETER Path + Specifies the path to an item to get its contents. +.PARAMETER Type + Specifies the type of tokens to examine. Accepted values include "Comment" and "Keyword". +.PARAMETER Token + Specifies the contents to filter on when examining a supplied token. +.PARAMETER Index + Specifies the line to move a statement to. Each line in an item has a corresponding + index, starting from 0. +.EXAMPLE + Move-Statement -Path $Path -Type 'Comment', 'Keyword' -Token '#Requires', 'using' -Index 0 + + Moves any using directives or #Requires statements to the top of a file. +.NOTES + Copy/Paste from LDModuleBuilder +#> + [CmdletBinding(SupportsShouldProcess)] + + param( + [Parameter(Mandatory, + Position = 0, + ValueFromPipeline, + ValueFromPipelineByPropertyName)] + [ValidateNotNullOrEmpty()] + [ValidateScript({ Test-Path -Path $PSItem })] + [string] $Path, + + [Parameter(Position = 1, + ValueFromPipelineByPropertyName)] + [ValidateNotNullOrEmpty()] + [ValidateSet('Comment', 'Keyword')] + [string[]] $Type = ('Comment', 'Keyword'), + + [Parameter(Position = 2, + ValueFromPipelineByPropertyName)] + [ValidateNotNullOrEmpty()] + [string[]] $Token = ('#Requires', 'using'), + + [Parameter(Position = 3, + ValueFromPipelineByPropertyName)] + [ValidateNotNullOrEmpty()] + [int] $Index = 0 + ) + + process + { + try + { + $statements = [SortedSet[String]]::new( + [StringComparer]::InvariantCultureIgnoreCase + ) + + Write-Verbose -Message "Reading content from $Path..." + $content = [List[String]] ([File]::ReadLines($Path)) + + Write-Verbose -Message "Tokenizing content from $Path..." + $tokens = [PSParser]::Tokenize($content, [ref] $null) + + $match = $Token -join '|' + + Write-Verbose -Message 'Matching tokens...' + Write-Verbose -Message "Type = [$Type]; Token = [$Token]" + $keywords = $tokens.Where({ + $PSItem.Type -in $Type -and + $PSItem.Content -imatch "^(?:$match)" + }) + + if (-not $keywords) { + Write-Verbose -Message 'No matching tokens found! Returning...' + return + } + + $offset = 1 + foreach ($keyword in $keywords) + { + $line = $keyword.StartLine - $offset + + Write-Verbose -Message "Moving [$($content[$line])] to Index [$Index]..." + $null = $statements.Add($content[$line]), + $content.RemoveAt($line) + $offset++ + } + + [string[]] $comments, [string[]] $statements = $statements.Where({ + $PSItem -match '^#' + }, 'Split') + + foreach ($item in ($statements, $comments)) + { + $content.Insert($Index, '') + $content.InsertRange($Index, $item) + } + + if ($PSCmdlet.ShouldProcess($Path, $MyInvocation.MyCommand.Name)) + { + Write-Verbose -Message "Writing content to $Path..." + [File]::WriteAllLines($Path, $content) + } + } + catch + { + $PSCmdlet.ThrowTerminatingError($PSItem) + } + } +} + +taskx BuildModule @{ + Inputs = (Get-ChildItem -Path $Source -Recurse -Filter *.ps1) + Outputs = $ModulePath + Jobs = { + $sb = [Text.StringBuilder]::new() + $null = $sb.AppendLine('$Script:PSModuleRoot = $PSScriptRoot') + + # Class importer + $root = Join-Path -Path $source -ChildPath 'Classes' + "Load classes from [$root]" + $classFiles = Get-ChildItem -Path $root -Filter '*.ps1' -Recurse | + Where-Object Name -notlike '*.Tests.ps1' + + $classes = @{} + + foreach($file in $classFiles) + { + $name = $file.BaseName + $classes[$name] = @{ + Name = $name + Path = $file.FullName + } + $data = Get-Content $file.fullname + foreach($line in $data) + { + if($line -match "\s+($Name)\s*(:|requires)\s*(?\w*)") + { + $classes[$name].Base = $Matches.baseclass + } + } + } + + $importOrder = foreach($className in $classes.Keys) + { + Import-ClassOrder $className $classes + } + + foreach($class in $importOrder) + { + "Importing [$class]..." + $null = $sb.AppendLine("# .$class") + $null = $sb.AppendLine([IO.File]::ReadAllText($class)) + } + + foreach ($folder in ($Folders -ne 'Classes')) + { + if (Test-Path -Path "$Source\$folder") + { + $null = $sb.AppendLine("# Importing from [$Source\$folder]") + $files = Get-ChildItem -Path "$Source\$folder\*.ps1" | + Where-Object 'Name' -notlike '*.Tests.ps1' + + foreach ($file in $files) + { + $name = $file.Fullname.Replace($buildroot, '') + + "Importing [$($file.FullName)]..." + $null = $sb.AppendLine("# .$name") + $null = $sb.AppendLine([IO.File]::ReadAllText($file.FullName)) + } + } + } + + "Creating Module [$ModulePath]..." + $null = New-Item -Path (Split-path $ModulePath) -ItemType Directory -ErrorAction SilentlyContinue -Force + Set-Content -Path $ModulePath -Value $sb.ToString() -Encoding 'UTF8' + + 'Moving "#Requires" statements and "using" directives...' + Move-Statement -Path $ModulePath -Type 'Comment', 'Keyword' -Token '#Requires', 'using' -Index 0 + } +} diff --git a/BuildTasks/Clean.Task.ps1 b/BuildTasks/Clean.Task.ps1 new file mode 100644 index 0000000..7ccead5 --- /dev/null +++ b/BuildTasks/Clean.Task.ps1 @@ -0,0 +1,12 @@ +task Clean { + if (Test-Path $Output) + { + "Cleaning Output files in [$Output]..." + $null = Get-ChildItem -Path $Output -File -Recurse | + Remove-Item -Force -ErrorAction 'Ignore' + + "Cleaning Output directories in [$Output]..." + $null = Get-ChildItem -Path $Output -Directory -Recurse | + Remove-Item -Recurse -Force -ErrorAction 'Ignore' + } +} diff --git a/BuildTasks/Compile.Task.ps1 b/BuildTasks/Compile.Task.ps1 new file mode 100644 index 0000000..28d1886 --- /dev/null +++ b/BuildTasks/Compile.Task.ps1 @@ -0,0 +1,14 @@ +taskx Compile @{ + If = (Get-ChildItem -Path $BuildRoot -Include *.csproj -Recurse) + Inputs = { + Get-ChildItem $BuildRoot -Recurse -File -Include *.cs + } + Outputs = "$Destination\bin\$ModuleName.dll" + Jobs = { + # This build command requires .Net Core + "Building Module" + $csproj = Get-ChildItem -Path $BuildRoot -Include *.csproj -Recurse + $folder = Split-Path $csproj + dotnet build $folder -c Release -o $Destination\bin + } +} diff --git a/BuildTasks/Copy.Task.ps1 b/BuildTasks/Copy.Task.ps1 new file mode 100644 index 0000000..2daadd3 --- /dev/null +++ b/BuildTasks/Copy.Task.ps1 @@ -0,0 +1,29 @@ + +task Copy { + "Creating Directory [$Destination]..." + $null = New-Item -ItemType 'Directory' -Path $Destination -ErrorAction 'Ignore' + + $files = Get-ChildItem -Path $Source -File | + Where-Object 'Name' -notmatch "$ModuleName\.ps[dm]1" + + foreach ($file in $files) + { + 'Creating [.{0}]...' -f $file.FullName.Replace($buildroot, '') + Copy-Item -Path $file.FullName -Destination $Destination -Force + } + + $directories = Get-ChildItem -Path $Source -Directory | + Where-Object 'Name' -notin $Folders + + foreach ($directory in $directories) + { + 'Creating [.{0}]...' -f $directory.FullName.Replace($buildroot, '') + Copy-Item -Path $directory.FullName -Destination $Destination -Recurse -Force + } + + $license = Join-Path -Path $buildroot -ChildPath 'LICENSE' + if ( Test-Path -Path $license -PathType Leaf ) + { + Copy-Item -Path $license -Destination $Destination + } +} diff --git a/BuildTasks/GenerateHelp.Task.ps1 b/BuildTasks/GenerateHelp.Task.ps1 new file mode 100644 index 0000000..51d3a36 --- /dev/null +++ b/BuildTasks/GenerateHelp.Task.ps1 @@ -0,0 +1,23 @@ + +task GenerateHelp { + if (-not(Get-ChildItem -Path $DocsPath -Filter '*.md' -Recurse -ErrorAction 'Ignore')) + { + "No Markdown help files to process. Skipping help file generation..." + return + } + + $locales = (Get-ChildItem -Path $DocsPath -Directory).Name + foreach ($locale in $locales) + { + $params = @{ + ErrorAction = 'SilentlyContinue' + Force = $true + OutputPath = "$Destination\en-US" + Path = "$DocsPath\en-US" + } + + # Generate the module's primary MAML help file. + "Creating new External help for [$ModuleName]..." + $null = New-ExternalHelp @params + } +} diff --git a/BuildTasks/GenerateMarkdown.Task.ps1 b/BuildTasks/GenerateMarkdown.Task.ps1 new file mode 100644 index 0000000..cfb4204 --- /dev/null +++ b/BuildTasks/GenerateMarkdown.Task.ps1 @@ -0,0 +1,41 @@ + +task GenerateMarkdown { + $module = Import-Module -FullyQualifiedName $ManifestPath -Force -PassThru + + try + { + if ($module.ExportedFunctions.Count -eq 0) + { + 'No functions have been exported for this module. Skipping Markdown generation...' + return + } + + if (Get-ChildItem -Path $DocsPath -Filter '*.md' -Recurse) + { + $items = Get-ChildItem -Path $DocsPath -Directory -Recurse + foreach ($item in $items) + { + "Updating Markdown help in [$($item.BaseName)]..." + $null = Update-MarkdownHelp -Path $item.FullName -AlphabeticParamsOrder + } + } + + $params = @{ + AlphabeticParamsOrder = $true + ErrorAction = 'SilentlyContinue' + Locale = 'en-US' + Module = $ModuleName + OutputFolder = "$DocsPath\en-US" + WithModulePage = $true + } + + # ErrorAction is set to SilentlyContinue so this + # command will not overwrite an existing Markdown file. + "Creating new Markdown help for [$ModuleName]..." + $null = New-MarkdownHelp @params + } + finally + { + Remove-Module -Name $ModuleName -Force + } +} diff --git a/BuildTasks/ImportDevModule.Task.ps1 b/BuildTasks/ImportDevModule.Task.ps1 new file mode 100644 index 0000000..c868aa7 --- /dev/null +++ b/BuildTasks/ImportDevModule.Task.ps1 @@ -0,0 +1,4 @@ + +task ImportDevModule { + ImportModule -Path "$Source\$ModuleName.psd1" -Force +} diff --git a/BuildTasks/ImportModule.Task.ps1 b/BuildTasks/ImportModule.Task.ps1 new file mode 100644 index 0000000..a903b45 --- /dev/null +++ b/BuildTasks/ImportModule.Task.ps1 @@ -0,0 +1,33 @@ +function ImportModule +{ + param( + [string]$path, + [switch]$PassThru + ) + + + if (-not(Test-Path -Path $path)) + { + "Cannot find [$path]." + Write-Error -Message "Could not find module manifest [$path]" + } + else + { + $file = Get-Item $path + $name = $file.BaseName + + $loaded = Get-Module -Name $name -All -ErrorAction Ignore + if ($loaded) + { + "Unloading Module [$name] from a previous import..." + $loaded | Remove-Module -Force + } + + "Importing Module [$name] from [$($file.fullname)]..." + Import-Module -Name $file.fullname -Force -PassThru:$PassThru + } +} + +task ImportModule { + ImportModule -Path $ManifestPath +} diff --git a/BuildTasks/Install.Task.ps1 b/BuildTasks/Install.Task.ps1 new file mode 100644 index 0000000..1727f86 --- /dev/null +++ b/BuildTasks/Install.Task.ps1 @@ -0,0 +1,21 @@ + +task Install Uninstall, { + $version = [version] (Get-Metadata -Path $manifestPath -PropertyName 'ModuleVersion') + + $path = $env:PSModulePath.Split(';').Where({ + $_ -like 'C:\Users\*' + }, 'First', 1) + + if ($path -and (Test-Path -Path $path)) + { + "Using [$path] as base path..." + $path = Join-Path -Path $path -ChildPath $ModuleName + $path = Join-Path -Path $path -ChildPath $version + + "Creating directory at [$path]..." + New-Item -Path $path -ItemType 'Directory' -Force -ErrorAction 'Ignore' + + "Copying items from [$Destination] to [$path]..." + Copy-Item -Path "$Destination\*" -Destination $path -Recurse -Force + } +} diff --git a/BuildTasks/InvokeBuildInit.ps1 b/BuildTasks/InvokeBuildInit.ps1 new file mode 100644 index 0000000..620e51d --- /dev/null +++ b/BuildTasks/InvokeBuildInit.ps1 @@ -0,0 +1,31 @@ +Write-Verbose "Initializing build variables" -Verbose +Write-Verbose " Existing BuildRoot [$BuildRoot]" -Verbose + +$Script:DocsPath = Join-Path -Path $BuildRoot -ChildPath 'Docs' +Write-Verbose " DocsPath [$DocsPath]" -Verbose + +$Script:Output = Join-Path -Path $BuildRoot -ChildPath 'Output' +Write-Verbose " Output [$Output]" -Verbose + +$Script:Source = Join-Path -Path $BuildRoot -ChildPath $ModuleName +Write-Verbose " Source [$Source]" -Verbose + +$Script:Destination = Join-Path -Path $Output -ChildPath $ModuleName +Write-Verbose " Destination [$Destination]" -Verbose + +$Script:ManifestPath = Join-Path -Path $Destination -ChildPath "$ModuleName.psd1" +Write-Verbose " ManifestPath [$ManifestPath]" -Verbose + +$Script:ModulePath = Join-Path -Path $Destination -ChildPath "$ModuleName.psm1" +Write-Verbose " ModulePath [$ModulePath]" -Verbose + +$Script:Folders = 'Classes', 'Includes', 'Internal', 'Private', 'Public', 'Resources' +Write-Verbose " Folders [$Folders]" -Verbose + +$Script:TestFile = "$BuildRoot\Output\TestResults_PS$PSVersion`_$TimeStamp.xml" +Write-Verbose " TestFile [$TestFile]" -Verbose + +$Script:PSRepository = 'PSGallery' +Write-Verbose " PSRepository [$TestFile]" -Verbose + +function taskx($Name, $Parameters) { task $Name @Parameters -Source $MyInvocation } diff --git a/BuildTasks/Pester.Task.ps1 b/BuildTasks/Pester.Task.ps1 new file mode 100644 index 0000000..3a4f2fe --- /dev/null +++ b/BuildTasks/Pester.Task.ps1 @@ -0,0 +1,34 @@ +task Pester { + $requiredPercent = $Script:CodeCoveragePercent + + $params = @{ + OutputFile = $testFile + OutputFormat = 'NUnitXml' + PassThru = $true + Path = 'Tests' + Show = 'Failed', 'Fails', 'Summary' + Tag = 'Build' + } + + if($requiredPercent -gt 0.00) + { + $params['CodeCoverage'] = 'Output\*\*.psm1' + $params['CodeCoverageOutputFile'] = 'Output\codecoverage.xml' + } + + $results = Invoke-Pester @params + if ($results.FailedCount -gt 0) + { + Write-Error -Message "Failed [$($results.FailedCount)] Pester tests." + } + + if($results.codecoverage.NumberOfCommandsAnalyzed -gt 0) + { + $codeCoverage = $results.codecoverage.NumberOfCommandsExecuted / $results.codecoverage.NumberOfCommandsAnalyzed + + if($codeCoverage -lt $requiredPercent) + { + Write-Error ("Failed Code Coverage [{0:P}] below {1:P}" -f $codeCoverage,$requiredPercent) + } + } +} diff --git a/BuildTasks/PublishModule.Task.ps1 b/BuildTasks/PublishModule.Task.ps1 new file mode 100644 index 0000000..bf01c12 --- /dev/null +++ b/BuildTasks/PublishModule.Task.ps1 @@ -0,0 +1,31 @@ +task PublishModule { + + if ( $ENV:BHBuildSystem -ne 'Unknown' -and + $ENV:BHBranchName -eq "master" -and + -not [string]::IsNullOrWhiteSpace($ENV:nugetapikey)) + { + $publishModuleSplat = @{ + Path = $Destination + NuGetApiKey = $ENV:nugetapikey + Verbose = $true + Force = $true + Repository = $PSRepository + ErrorAction = 'Stop' + } + "Files in module output:" + Get-ChildItem $Destination -Recurse -File | + Select-Object -Expand FullName + + "Publishing [$Destination] to [$PSRepository]" + + Publish-Module @publishModuleSplat + } + else + { + "Skipping deployment: To deploy, ensure that...`n" + + "`t* You are in a known build system (Current: $ENV:BHBuildSystem)`n" + + "`t* You are committing to the master branch (Current: $ENV:BHBranchName) `n" + + "`t* The repository APIKey is defined in `$ENV:nugetapikey (Current: $(![string]::IsNullOrWhiteSpace($ENV:nugetapikey))) `n" + + "`t* This is not a pull request" + } +} diff --git a/BuildTasks/PublishVersion.Task.ps1 b/BuildTasks/PublishVersion.Task.ps1 new file mode 100644 index 0000000..79c9eba --- /dev/null +++ b/BuildTasks/PublishVersion.Task.ps1 @@ -0,0 +1,7 @@ +task PublishVersion { + [version] $sourceVersion = (Get-Metadata -Path $manifestPath -PropertyName 'ModuleVersion') + "##vso[build.updatebuildnumber]$sourceVersion" + + # Do the same for appveyor + # https://www.appveyor.com/docs/build-worker-api/#update-build-details +} diff --git a/BuildTasks/SetVersion.Task.ps1 b/BuildTasks/SetVersion.Task.ps1 new file mode 100644 index 0000000..1587856 --- /dev/null +++ b/BuildTasks/SetVersion.Task.ps1 @@ -0,0 +1,125 @@ +function GetModulePublicInterfaceMap +{ + param($Path) + $module = ImportModule -Path $Path -PassThru + $exportedCommands = @( + $module.ExportedFunctions.values + $module.ExportedCmdlets.values + $module.ExportedAliases.values + ) + + foreach($command in $exportedCommands) + { + foreach ($parameter in $command.Parameters.Keys) + { + if($false -eq $command.Parameters[$parameter].IsDynamic) + { + '{0}:{1}' -f $command.Name, $command.Parameters[$parameter].Name + foreach ($alias in $command.Parameters[$parameter].Aliases) + { + '{0}:{1}' -f $command.Name, $alias + } + } + } + } +} + +task SetVersion { + $version = [version]"0.1.0" + $publishedModule = $null + $bumpVersionType = 'Patch' + $versionStamp = (git rev-parse origin/master) + (git rev-parse head) + + "Load current version" + [version] $sourceVersion = (Get-Metadata -Path $manifestPath -PropertyName 'ModuleVersion') + " Source version [$sourceVersion]" + + $downloadFolder = Join-Path -Path $output downloads + $null = New-Item -ItemType Directory -Path $downloadFolder -Force -ErrorAction Ignore + + $versionFile = Join-Path $downloadFolder versionfile + if(Test-Path $versionFile) + { + $versionFileData = Get-Content $versionFile -raw + if($versionFileData -eq $versionStamp) + { + continue + } + } + + "Checking for published version" + $publishedModule = Find-Module -Name $ModuleName -ErrorAction 'Ignore' | + Sort-Object -Property {[version]$_.Version} -Descending | + Select -First 1 + + if($null -ne $publishedModule) + { + [version] $publishedVersion = $publishedModule.Version + " Published version [$publishedVersion]" + + $version = $publishedVersion + + "Downloading published module to check for breaking changes" + $publishedModule | Save-Module -Path $downloadFolder + + [System.Collections.Generic.HashSet[string]] $publishedInterface = @(GetModulePublicInterfaceMap -Path (Join-Path $downloadFolder $ModuleName)) + [System.Collections.Generic.HashSet[string]] $buildInterface = @(GetModulePublicInterfaceMap -Path $ManifestPath) + + if (-not $publishedInterface.IsSubsetOf($buildInterface)) + { + $bumpVersionType = 'Major' + } + elseif ($publishedInterface.count -ne $buildInterface.count) + { + $bumpVersionType = 'Minor' + } + } + + if ($version -lt ([version] '1.0.0')) + { + "Module is still in beta; don't bump major version." + if ($bumpVersionType -eq 'Major') + { + $bumpVersionType = 'Minor' + } + else + { + $bumpVersionType = 'Patch' + } + } + + " Steping version [$bumpVersionType]" + $version = [version] (Step-Version -Version $version -Type $bumpVersionType) + + " Comparing to source version [$sourceVersion]" + if($sourceVersion -gt $version) + { + " Using existing version" + $version = $sourceVersion + } + + if ( -not [string]::IsNullOrEmpty( $env:Build_BuildID ) ) + { + $build = $env:Build_BuildID + $version = [version]::new($version.Major, $version.Minor, $version.Build, $build) + } + elseif ( -not [string]::IsNullOrEmpty( $env:APPVEYOR_BUILD_ID ) ) + { + $build = $env:APPVEYOR_BUILD_ID + $version = [version]::new($version.Major, $version.Minor, $version.Build, $build) + } + + " Setting version [$version]" + Update-Metadata -Path $ManifestPath -PropertyName 'ModuleVersion' -Value $version + + (Get-Content -Path $ManifestPath -Raw -Encoding UTF8) | + ForEach-Object {$_.TrimEnd()} | + Set-Content -Path $ManifestPath -Encoding UTF8 + + Set-Content -Path $versionFile -Value $versionStamp -NoNewline -Encoding UTF8 + + if(Test-Path $BuildRoot\fingerprint) + { + Remove-Item $BuildRoot\fingerprint + } +} diff --git a/BuildTasks/Uninstall.Task.ps1 b/BuildTasks/Uninstall.Task.ps1 new file mode 100644 index 0000000..61e0539 --- /dev/null +++ b/BuildTasks/Uninstall.Task.ps1 @@ -0,0 +1,28 @@ + +task Uninstall { + 'Unloading Modules...' + Get-Module -Name $ModuleName -ErrorAction 'Ignore' | Remove-Module -Force + + 'Uninstalling Module packages...' + $modules = Get-Module $ModuleName -ErrorAction 'Ignore' -ListAvailable + foreach ($module in $modules) + { + Uninstall-Module -Name $module.Name -RequiredVersion $module.Version -ErrorAction 'Ignore' + } + + 'Cleaning up manually installed Modules...' + $path = $env:PSModulePath.Split(';').Where({ + $_ -like 'C:\Users\*' + }, 'First', 1) + + $path = Join-Path -Path $path -ChildPath $ModuleName + if ($path -and (Test-Path -Path $path)) + { + 'Removing files... (This may fail if any DLLs are in use.)' + Get-ChildItem -Path $path -File -Recurse | + Remove-Item -Force | ForEach-Object 'FullName' + + 'Removing folders... (This may fail if any DLLs are in use.)' + Remove-Item $path -Recurse -Force + } +} diff --git a/BuildTasks/UpdateSource.Task.ps1 b/BuildTasks/UpdateSource.Task.ps1 new file mode 100644 index 0000000..fa46401 --- /dev/null +++ b/BuildTasks/UpdateSource.Task.ps1 @@ -0,0 +1,6 @@ +task UpdateSource { + Copy-Item -Path $ManifestPath -Destination "$Source\$ModuleName.psd1" + + $content = Get-Content -Path "$Source\$ModuleName.psd1" -Raw -Encoding UTF8 + $content.Trim() | Set-Content -Path "$Source\$ModuleName.psd1" -Encoding UTF8 +} diff --git a/LICENSE b/LICENSE index 8a60580..0d1df97 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2017 Kevin Marquette +Copyright (c) 2019 Kevin Marquette Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/Module.build.ps1 b/Module.build.ps1 new file mode 100644 index 0000000..f549433 --- /dev/null +++ b/Module.build.ps1 @@ -0,0 +1,15 @@ +$Script:ModuleName = Get-ChildItem .\*\*.psm1 | Select-object -ExpandProperty BaseName +$Script:CodeCoveragePercent = 0.0 # 0 to 1 +. $psscriptroot\BuildTasks\InvokeBuildInit.ps1 + +task Default Build, Helpify, Test, UpdateSource +task Build Copy, Compile, BuildModule, BuildManifest, SetVersion +task Helpify GenerateMarkdown, GenerateHelp +task Test Build, ImportModule, Pester +task Publish Build, PublishVersion, Helpify, Test, PublishModule +task TFS Clean, Build, PublishVersion, Helpify, Test +task DevTest ImportDevModule, Pester + +Write-Host 'Import common tasks' +Get-ChildItem -Path $buildroot\BuildTasks\*.Task.ps1 | + ForEach-Object {Write-Host $_.FullName;. $_.FullName} diff --git a/ScriptAnalyzerSettings.psd1 b/ScriptAnalyzerSettings.psd1 new file mode 100644 index 0000000..e42e87d --- /dev/null +++ b/ScriptAnalyzerSettings.psd1 @@ -0,0 +1,34 @@ +@{ + # Use Severity when you want to limit the generated diagnostic records to a + # subset of: Error, Warning and Information. + # Uncomment the following line if you only want Errors and Warnings but + # not Information diagnostic records. + Severity = @('Error','Warning') + + # Use IncludeRules when you want to run only a subset of the default rule set. + #IncludeRules = @('PSAvoidDefaultValueSwitchParameter', + # 'PSMisleadingBacktick', + # 'PSMissingModuleManifestField', + # 'PSReservedCmdletChar', + # 'PSReservedParams', + # 'PSShouldProcess', + # 'PSUseApprovedVerbs', + # 'PSUseDeclaredVarsMoreThanAssigments') + + # Use ExcludeRules when you want to run most of the default set of rules except + # for a few rules you wish to "exclude". Note: if a rule is in both IncludeRules + # and ExcludeRules, the rule will be excluded. + ExcludeRules = @('PSUseToExportFieldsInManifest','PSMissingModuleManifestField') + + # You can use the following entry to supply parameters to rules that take parameters. + # For instance, the PSAvoidUsingCmdletAliases rule takes a whitelist for aliases you + # want to allow. + Rules = @{ + # Do not flag 'cd' alias. + PSAvoidUsingCmdletAliases = @{Whitelist = @('Where','Select')} + + # Check if your script uses cmdlets that are compatible on PowerShell Core, + # version 6.0.0-alpha, on Linux. + # PSUseCompatibleCmdlets = @{Compatibility = @("core-6.0.0-alpha-linux")} + } +} diff --git a/Tests/Project/Help.Tests.ps1 b/Tests/Project/Help.Tests.ps1 new file mode 100644 index 0000000..954cf88 --- /dev/null +++ b/Tests/Project/Help.Tests.ps1 @@ -0,0 +1,33 @@ +$Script:ModuleRoot = Split-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -Parent +$Script:ModuleName = $Script:ModuleName = Get-ChildItem $ModuleRoot\*\*.psm1 | Select-object -ExpandProperty BaseName + +Describe "Public commands have comment-based or external help" -Tags 'Build' { + $functions = Get-Command -Module $ModuleName + $help = foreach ($function in $functions) { + Get-Help -Name $function.Name + } + + foreach ($node in $help) + { + Context $node.Name { + It "Should have a Description or Synopsis" { + ($node.Description + $node.Synopsis) | Should Not BeNullOrEmpty + } + + It "Should have an Example" { + $node.Examples | Should Not BeNullOrEmpty + $node.Examples | Out-String | Should -Match ($node.Name) + } + + foreach ($parameter in $node.Parameters.Parameter) + { + if ($parameter -notmatch 'WhatIf|Confirm') + { + It "Should have a Description for Parameter [$($parameter.Name)]" { + $parameter.Description.Text | Should Not BeNullOrEmpty + } + } + } + } + } +} diff --git a/Tests/Project/Module.Tests.ps1 b/Tests/Project/Module.Tests.ps1 new file mode 100644 index 0000000..f652dc4 --- /dev/null +++ b/Tests/Project/Module.Tests.ps1 @@ -0,0 +1,45 @@ +$Script:ModuleRoot = Split-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -Parent +$Script:ModuleName = $Script:ModuleName = Get-ChildItem $ModuleRoot\*\*.psm1 | Select-object -ExpandProperty BaseName + +$Script:SourceRoot = Join-Path -Path $ModuleRoot -ChildPath $ModuleName + +Describe "All commands pass PSScriptAnalyzer rules" -Tag 'Build' { + $rules = "$ModuleRoot\ScriptAnalyzerSettings.psd1" + $scripts = Get-ChildItem -Path $SourceRoot -Include '*.ps1', '*.psm1', '*.psd1' -Recurse | + Where-Object FullName -notmatch 'Classes' + + foreach ($script in $scripts) + { + Context $script.FullName { + $results = Invoke-ScriptAnalyzer -Path $script.FullName -Settings $rules + if ($results) + { + foreach ($rule in $results) + { + It $rule.RuleName { + $message = "{0} Line {1}: {2}" -f $rule.Severity, $rule.Line, $rule.Message + $message | Should Be "" + } + } + } + else + { + It "Should not fail any rules" { + $results | Should BeNullOrEmpty + } + } + } + } +} + +Describe "Public commands have Pester tests" -Tag 'Build' { + $commands = Get-Command -Module $ModuleName + + foreach ($command in $commands.Name) + { + $file = Get-ChildItem -Path "$ModuleRoot\Tests" -Include "$command.Tests.ps1" -Recurse + It "Should have a Pester test for [$command]" { + $file.FullName | Should Not BeNullOrEmpty + } + } +} diff --git a/appveyor.yml b/appveyor.yml index 4918e7a..5e0a4b1 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -16,4 +16,4 @@ build: false #Kick off the CI/CD pipeline test_script: - - ps: . .\build.ps1 \ No newline at end of file + - ps: . .\build.ps1 diff --git a/azure-pipelines.yml b/azure-pipelines.yml new file mode 100644 index 0000000..d976f2c --- /dev/null +++ b/azure-pipelines.yml @@ -0,0 +1,25 @@ +# Starter pipeline +# Start with a minimal pipeline that you can customize to build and deploy your code. +# Add steps that build, run tests, deploy, and more: +# https://aka.ms/yaml + +#resources: +#- repo: self +# clean: true +# fetchDepth: 1 + +trigger: + batch: true + branches: + include: + - master + +pool: + vmImage: 'Ubuntu 16.04' + +steps: +- script: pwsh -File build.ps1 Publish + displayName: 'Build and Publish Module' + env: + nugetapikey: $(nugetapikey) + diff --git a/build.ps1 b/build.ps1 index b257254..ec892fc 100644 --- a/build.ps1 +++ b/build.ps1 @@ -1,21 +1,38 @@ -<# -.Description -Installs and loads all the required modules for the build. -.Author -Warren F. (RamblingCookieMonster) -#> +[CmdletBinding()] -[cmdletbinding()] -param ($Task = 'Default') +param($Task = 'Default') -# Grab nuget bits, install modules, set build variables, start build. -Get-PackageProvider -Name NuGet -ForceBootstrap | Out-Null +$Script:Modules = @( + 'BuildHelpers', + 'InvokeBuild', + 'Pester', + 'platyPS', + 'PSScriptAnalyzer', + 'DependsOn' +) -Install-Module Psake, PSDeploy, BuildHelpers, PSScriptAnalyzer -force -Install-Module Pester -Force -SkipPublisherCheck -Import-Module Psake, BuildHelpers, PSScriptAnalyzer +$Script:ModuleInstallScope = 'CurrentUser' + +'Starting build...' +'Installing module dependencies...' + +Get-PackageProvider -Name 'NuGet' -ForceBootstrap | Out-Null + +Install-Module -Name $Script:Modules -Scope $Script:ModuleInstallScope -Force -SkipPublisherCheck Set-BuildEnvironment +Get-ChildItem Env:BH* +Get-ChildItem Env:APPVEYOR* -Invoke-psake -buildFile .\psake.ps1 -taskList $Task -nologo -exit ( [int]( -not $psake.build_success ) ) \ No newline at end of file +$Error.Clear() + +'Invoking build...' + +Invoke-Build $Task -Result 'Result' +if ($Result.Error) +{ + $Error[-1].ScriptStackTrace | Out-String + exit 1 +} + +exit 0