Indented.Build.psm1
using namespace System.Text function GetBranchName { [OutputType([String])] param ( ) git rev-parse --abbrev-ref HEAD } function GetBuildSystem { [OutputType([String])] param ( ) if ($env:APPVEYOR -eq $true) { return 'AppVeyor' } if ($env:JENKINS_URL) { return 'Jenkins' } return 'Unknown' } function GetLastCommitMessage { [OutputType([String])] param ( ) return (git log -1 --pretty=%B | Where-Object { $_ } | Out-String).Trim() } function GetProjectRoot { [OutputType([System.IO.DirectoryInfo])] param ( ) [System.IO.DirectoryInfo](Get-Item (git rev-parse --show-toplevel)).FullName } filter GetSourcePath { [CmdletBinding()] [OutputType([System.IO.DirectoryInfo], [System.IO.DirectoryInfo[]])] param ( [Parameter(Mandatory, ValueFromPipeline)] [System.IO.DirectoryInfo]$ProjectRoot ) try { Push-Location $ProjectRoot # Try and find a match by searching for psd1 files $sourcePath = Get-ChildItem .\*\*.psd1 | Where-Object { $_.BaseName -eq $_.Directory.Name } | ForEach-Object { $_.Directory } if ($sourcePath) { return $sourcePath } else { if (Test-Path (Join-Path $ProjectRoot $ProjectRoot.Name)) { return [System.IO.DirectoryInfo](Join-Path $ProjectRoot $ProjectRoot.Name) } } throw 'Unable to determine the source path' } catch { $pscmdlet.ThrowTerminatingError($_) } finally { Pop-Location } } function GetVersion { [OutputType([Version])] param ( # The path to the a module manifest file. [String]$Path ) if ($Path -and (Test-Path $Path)) { $manifestContent = Import-PowerShellDataFile $Path $versionString = $manifestContent.ModuleVersion $version = [Version]'0.0.0' if ([Version]::TryParse($versionString, [Ref]$version)) { if ($version.Build -eq -1) { return [Version]::new($version.Major, $version.Minor, 0) } else { return $version } } } return [Version]'1.0.0' } function UpdateVersion { [OutputType([Version])] param ( # The current version number. [Parameter(Position = 1, ValueFromPipeline = $true)] [Version]$Version, # The release type. [ValidateSet('Build', 'Minor', 'Major', 'None')] [String]$ReleaseType = 'Build' ) process { $arguments = switch ($ReleaseType) { 'Major' { ($Version.Major + 1), 0, 0 } 'Minor' { $Version.Major, ($Version.Minor + 1), 0 } 'Build' { $Version.Major, $Version.Minor, ($Version.Build + 1) } 'None' { return $Version } } New-Object Version($arguments) } } function BuildTask { <# .SYNOPSIS Create a build task object. .DESCRIPTION A build task is a predefined task used to build well-structured PowerShell projects. #> [CmdletBinding()] [OutputType('BuildTask')] param ( # The name of the task. [Parameter(Mandatory)] [String]$Name, # The stage during which the task will be invoked. [Parameter(Mandatory)] [String]$Stage, # Where the task should appear in the build order respective to the stage. [Int32]$Order = 1024, # The task will only be invoked if the filter condition is true. [ScriptBlock]$If = { $true }, # The task implementation. [Parameter(Mandatory)] [ScriptBlock]$Definition ) [PSCustomObject]@{ Name = $Name Stage = $Stage If = $If Order = $Order Definition = $Definition } | Add-Member -TypeName 'BuildTask' -PassThru } filter Enable-Metadata { <# .SYNOPSIS Enable a metadata property which has been commented out. .DESCRIPTION This function is derived Get and Update-Metadata from PoshCode\Configuration. A boolean value is returned indicating if the property is available in the metadata file. If the property does not exist, or exists more than once within the specified file this command will return false. .INPUTS System.String .EXAMPLE Enable-Metadata .\module.psd1 -PropertyName RequiredAssemblies Enable an existing (commented) RequiredAssemblies property within the module.psd1 file. .NOTES Change log: 04/08/2016 - Chris Dent - Created. #> [CmdletBinding()] [OutputType([Boolean])] param ( # A valid metadata file or string containing the metadata. [Parameter(ValueFromPipelineByPropertyName, Position = 0)] [ValidateScript( { Test-Path $_ -PathType Leaf } )] [Alias("PSPath")] [String]$Path, # The property to enable. [String]$PropertyName ) # If the element can be found using Get-Metadata leave it alone and return true $shouldEnable = $false try { $null = Get-Metadata @psboundparameters -ErrorAction Stop } catch [System.Management.Automation.ItemNotFoundException] { # The function will only execute where the requested value is not present $shouldEnable = $true } catch { # Ignore other errors which may be raised by Get-Metadata except path not found. if ($_.Exception.Message -eq 'Path must point to a .psd1 file') { $pscmdlet.ThrowTerminatingError($_) } } if (-not $shouldEnable) { return $true } $manifestContent = Get-Content $Path -Raw $tokens = $parseErrors = $null $ast = [System.Management.Automation.Language.Parser]::ParseInput( $manifestContent, $Path, [Ref]$tokens, [Ref]$parseErrors ) # Attempt to find a comment which matches the requested property $regex = '^ *# *({0}) *=' -f $PropertyName $existingValue = @($tokens | Where-Object { $_.Kind -eq 'Comment' -and $_.Text -match $regex }) if ($existingValue.Count -eq 1) { $manifestContent = $ast.Extent.Text.Remove( $existingValue.Extent.StartOffset, $existingValue.Extent.EndOffset - $existingValue.Extent.StartOffset ).Insert( $existingValue.Extent.StartOffset, $existingValue.Extent.Text -replace '^# *' ) try { Set-Content -Path $Path -Value $manifestContent -NoNewline -ErrorAction Stop $true } catch { $false } } elseif ($existingValue.Count -eq 0) { # Item not found Write-Warning "Cannot find disabled property '$PropertyName' in $Path" $false } else { # Ambiguous match Write-Warning "Found more than one '$PropertyName' in $Path" $false } } function Export-BuildScript { <# .SYNOPSIS Export a build script for use with Invoke-Build. .DESCRIPTION Export a build script for use with Invoke-Build. .INPUTS BuildInfo (from Get-BuildInfo) #> [CmdletBinding()] [OutputType([String])] param ( # The build information object is used to determine which tasks are applicable. [Parameter(ValueFromPipeline)] [PSTypeName('BuildInfo')] [PSObject]$BuildInfo = (Get-BuildInfo), # By default the build system is automatically discovered. The BuildSystem parameter overrides any automatically discovered value. Tasks associated with the build system are added to the generated script. [String]$BuildSystem, # If specified, the build script will be written to the the specified path. By default the build script is written (as a string) to the console. [String]$Path ) if ($BuildSystem) { $BuildInfo.BuildSystem = $BuildSystem } $script = [StringBuilder]::new() $null = $script.AppendLine('param ('). AppendLine(' [PSTypeName("BuildInfo")]'). AppendLine(' [ValidateCount(1, 1)]'). AppendLine(' [PSObject[]]$BuildInfo'). AppendLine(')'). AppendLine() $tasks = $BuildInfo | Get-BuildTask | Sort-Object { switch ($_.Stage) { 'Setup' { 1; break } 'Build' { 2; break } 'Test' { 3; break } 'Pack' { 4; break } 'Publish' { 5; break } } }, Order, Name # Build the wrapper tasks and insert the block at the top of the script $taskSets = [StringBuilder]::new() # Add a default task set $null = $taskSets.AppendLine('task default Setup,'). AppendLine(' Build,'). AppendLine(' Test,'). AppendLine(' Pack'). AppendLine() $tasks | Group-Object Stage | ForEach-Object { $indentLength = 'task '.Length + $_.Name.Length $null = $taskSets.AppendFormat('task {0} {1}', $_.Name, $_.Group[0].Name) foreach ($task in $_.Group | Select-Object -Skip 1) { $null = $taskSets.Append(','). AppendLine(). AppendFormat('{0} {1}', (' ' * $indentLength), $task.Name) } $null = $taskSets.AppendLine(). AppendLine() } $null = $script.Append($taskSets.ToString()) # Add supporting functions to create the BuildInfo object. (Get-Command Get-BuildInfo).ScriptBlock.Ast.FindAll( { param ( $ast ) $ast -is [Management.Automation.Language.CommandAst] }, $true ) | ForEach-Object GetCommandName | Select-Object -Unique | Sort-Object | ForEach-Object { $commandInfo = Get-Command $_ if ($commandInfo.Source -eq $myinvocation.MyCommand.ModuleName) { $null = $script.AppendFormat('function {0} {{', $commandInfo.Name). Append($commandInfo.Definition). AppendLine('}'). AppendLine() } } 'Enable-Metadata', 'Get-BuildInfo', 'Get-BuildItem' | ForEach-Object { $null = $script.AppendFormat('function {0} {{', $_). Append((Get-Command $_).Definition). AppendLine('}'). AppendLine() } # Add a generic task which allows BuildInfo to be retrieved $null = $script.AppendLine('task GetBuildInfo {'). AppendLine(' Get-BuildInfo'). AppendLine('}'). AppendLine() # Add a task that allows all all build jobs within the current project to run $null = $script.AppendLine('task BuildAll {'). AppendLine(' [String[]]$task = ${*}.Task.Name'). AppendLine(). AppendLine(' # Re-submit the build request without the BuildAll task'). AppendLine(' if ($task.Count -eq 1 -and $task[0] -eq "BuildAll") {'). AppendLine(' $task = "default"'). AppendLine(' } else {'). AppendLine(' $task = $task -ne "BuildAll"'). AppendLine(' }'). AppendLine(). AppendLine(' Get-BuildInfo | ForEach-Object {'). AppendLine(' Write-Host'). AppendLine(' "Building {0} ({1})" -f $_.ModuleName, $_.Version | Write-Host -ForegroundColor Green'). AppendLine(' Write-Host'). AppendLine(' Invoke-Build -BuildInfo $_ -Task $task'). AppendLine(' }'). AppendLine('}'). AppendLine() $tasks | ForEach-Object { $null = $script.AppendFormat('task {0}', $_.Name) if ($_.If -and $_.If.ToString().Trim() -ne '$true') { $null = $script.AppendFormat(' -If ({0})', $_.If.ToString().Trim()) } $null = $script.AppendLine(' {'). AppendLine($_.Definition.ToString().Trim("`r`n")). AppendLine('}'). AppendLine() } if ($Path) { $script.ToString() | Set-Content $Path } else { $script.ToString() } } function Get-BuildInfo { <# .SYNOPSIS Get properties required to build the project. .DESCRIPTION Get the properties required to build the project, or elements of the project. .EXAMPLE Get-BuildInfo Get build information for the current or any child directories. #> [CmdletBinding()] [OutputType('BuildInfo')] param ( # The tasks to execute, passed to Invoke-Build. BuildType is expected to be a broad description of the build, encompassing a set of tasks. [String[]]$BuildType = @('Setup', 'Build', 'Test'), # The release type. By default the release type is Build and the build version will increment. # # If the last commit message includes the phrase "major release" the release type will be reset to Major; If the last commit meessage includes "release" the releasetype will be reset to Minor. [ValidateSet('Build', 'Minor', 'Major', 'None')] [String]$ReleaseType = 'Build', # Generate build information for the specified path. [ValidateScript( { Test-Path $_ -PathType Container } )] [String]$Path = $pwd.Path ) try { $Path = $pscmdlet.GetUnresolvedProviderPathFromPSPath($Path) Push-Location $Path $projectRoot = GetProjectRoot $projectRoot | GetSourcePath | ForEach-Object { $buildInfo = [PSCustomObject]@{ ModuleName = $moduleName = $_.Parent.GetDirectories($_.Name).Name BuildType = $BuildType ReleaseType = $ReleaseType BuildSystem = GetBuildSystem Version = '1.0.0' CodeCoverageThreshold = 0.8 Repository = [PSCustomObject]@{ Branch = GetBranchName LastCommitMessage = GetLastCommitMessage } Path = [PSCustomObject]@{ ProjectRoot = $projectRoot Source = $_ SourceManifest = Join-Path $_ ('{0}.psd1' -f $moduleName) Package = '' Output = $output = [System.IO.DirectoryInfo](Join-Path $projectRoot 'output') Nuget = Join-Path $output 'packages' Manifest = '' RootModule = '' } } | Add-Member -TypeName 'BuildInfo' -PassThru $buildInfo.Version = GetVersion $buildInfo.Path.SourceManifest | UpdateVersion -ReleaseType $ReleaseType $buildInfo.Path.Package = [System.IO.DirectoryInfo](Join-Path $buildInfo.Path.ProjectRoot $buildInfo.Version) if ($buildInfo.Path.ProjectRoot.Name -ne $buildInfo.ModuleName) { $buildInfo.Path.Package = [System.IO.DirectoryInfo][System.IO.Path]::Combine($buildInfo.Path.ProjectRoot, 'build', $buildInfo.ModuleName, $buildInfo.Version) $buildInfo.Path.Output = [System.IO.DirectoryInfo][System.IO.Path]::Combine($buildInfo.Path.ProjectRoot, 'build', 'output', $buildInfo.ModuleName) $buildInfo.Path.Nuget = [System.IO.DirectoryInfo][System.IO.Path]::Combine($buildInfo.Path.ProjectRoot, 'build', 'output', 'packages') } $buildInfo.Path.Manifest = [System.IO.FileInfo](Join-Path $buildInfo.Path.Package ('{0}.psd1' -f $buildInfo.ModuleName)) $buildInfo.Path.RootModule = [System.IO.FileInfo](Join-Path $buildInfo.Path.Package ('{0}.psm1' -f $buildInfo.ModuleName)) $buildInfo } } catch { $pscmdlet.ThrowTerminatingError($_) } finally { Pop-Location } } function Get-BuildItem { <# .SYNOPSIS Get source items. .DESCRIPTION Get items from the source tree which will be consumed by the build process. This function centralises the logic required to enumerate files and folders within a project. #> [CmdletBinding()] [OutputType([System.IO.FileInfo], [System.IO.DirectoryInfo])] param ( # Gets items by type. # # ShouldMerge - *.ps1 files from enum*, class*, priv*, pub* and InitializeModule if present. # Static - Files which are not within a well known top-level folder. Captures help content in en-US, format files, configuration files, etc. [Parameter(Mandatory)] [ValidateSet('ShouldMerge', 'Static')] [String]$Type, # BuildInfo is used to determine the source path. [Parameter(Mandatory, ValueFromPipeline)] [PSTypeName('BuildInfo')] [PSObject]$BuildInfo, # Exclude script files containing PowerShell classes. [Switch]$ExcludeClass ) Push-Location $buildInfo.Path.Source $itemTypes = [Ordered]@{ enumeration = 'enum*' class = 'class*' private = 'priv*' public = 'pub*' initialisation = 'InitializeModule.ps1' } if ($Type -eq 'ShouldMerge') { foreach ($itemType in $itemTypes.Keys) { if ($itemType -ne 'class' -or ($itemType -eq 'class' -and -not $ExcludeClass)) { $items = Get-ChildItem $itemTypes[$itemType] -Recurse -ErrorAction SilentlyContinue | Where-Object { -not $_.PSIsContainer -and $_.Extension -eq '.ps1' -and $_.Length -gt 0 } $orderingFilePath = Join-Path $itemTypes[$itemType] 'order.txt' if (Test-Path $orderingFilePath) { [String[]]$order = Get-Content (Resolve-Path $orderingFilePath).Path $items = $items | Sort-Object { $index = $order.IndexOf($_.BaseName) if ($index -eq -1) { [Int32]::MaxValue } else { $index } }, Name } $items } } } elseif ($Type -eq 'Static') { [String[]]$exclude = $itemTypes.Values + '*.config', 'test*', 'doc', 'help', '.build*.ps1' # Should work, fails when testing. # Get-ChildItem -Exclude $exclude foreach ($item in Get-ChildItem) { $shouldExclude = $false foreach ($exclusion in $exclude) { if ($item.Name -like $exclusion) { $shouldExclude = $true } } if (-not $shouldExclude) { $item } } } Pop-Location } function Get-BuildTask { <# .SYNOPSIS Get build tasks. .DESCRIPTION Get the build tasks deemed to be applicable to this build. If the ListAvailable parameter is supplied, all available tasks will be returned. #> [CmdletBinding(DefaultParameterSetName = 'ForBuild')] [OutputType('BuildTask')] param ( # A build information object used to determine which tasks will apply to the current build. [Parameter(Mandatory, Position = 1, ValueFromPipeline, ParameterSetName = 'ForBuild')] [PSTypeName('BuildInfo')] [PSObject]$BuildInfo, # Filter tasks by task name. [String]$Name = '*', # List all available tasks, irrespective of conditions applied to the task. [Parameter(Mandatory, ParameterSetName = 'List')] [Switch]$ListAvailable ) begin { if (-not $Name.EndsWith('.ps1') -and -not $Name.EndsWith('*')) { $Name += '.ps1' } $path = Join-Path $psscriptroot 'task' if (-not $Script:buildTaskCache) { $Script:buildTaskCache = @{} Get-ChildItem $path -File -Filter *.ps1 -Recurse | ForEach-Object { $task = . $_.FullName $Script:buildTaskCache.Add($task.Name, $task) } } } process { if ($buildInfo) { Push-Location $buildInfo.Path.Source } try { $Script:buildTaskCache.Values | Where-Object { Write-Verbose ('Evaluating {0}' -f $_.Name) $_.Name -like $Name -and ($ListAvailable -or (& $_.If)) } } catch { Write-Error -Message ('Failed to evaluate task condition: {0}' -f $_.Exception.Message) -ErrorId 'ConditionEvaluationFailed' } if ($buildInfo) { Pop-Location } } } function Get-ChildBuildInfo { <# .SYNOPSIS Get items which can be built from child paths of the specified folder. .DESCRIPTION A folder may contain one or more items which can be built, this command may be used to discover individual projects. #> [CmdletBinding()] [OutputType('BuildInfo')] param ( # The starting point for the build search. [String]$Path = $pwd.Path, # Recurse to the specified depth when attempting to find projects which can be built. [Int32]$Depth = 4 ) Get-ChildItem $Path -Filter *.psd1 -File -Depth $Depth | Where-Object { $_.BaseName -eq $_.Directory.Name } | ForEach-Object { $currentPath = $_.Directory.FullName try { Get-BuildInfo -Path $currentPath } catch { Write-Debug ('{0}: {1}' -f $currentPath, $_.Exception.Message) } } } filter Start-Build { <# .SYNOPSIS Start a build. .DESCRIPTION Start a build using Invoke-Build. If a build script is not present one will be created. If a build script exists it will be used. If the build script exists this command is superfluous. #> [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')] [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingCmdletAliases', '')] [CmdletBinding()] [OutputType([Void])] param ( # The task categories to execute. [String[]]$BuildType = @('Setup', 'Build', 'Test'), # The release type to create. [ValidateSet('Build', 'Minor', 'Major', 'None')] [String]$ReleaseType = 'Build', [Parameter(ValueFromPipeline)] [PSTypeName('BuildInfo')] [PSObject[]]$BuildInfo = (Get-BuildInfo -BuildType $BuildType -ReleaseType $ReleaseType), [String]$ScriptName = '.build.ps1' ) foreach ($instance in $BuildInfo) { try { # If a build script exists in the project root, use it. if (Test-Path (Join-Path $instance.Path.ProjectRoot $ScriptName)) { $buildScript = Join-Path $instance.Path.ProjectRoot $ScriptName } else { # Otherwise assume the project contains more than one module and create a module specific script. $buildScript = Join-Path $instance.Path.Source $ScriptName } # Remove the script if it is created by this process. Export-BuildScript can be used to create a persistent script. $shouldClean = $false if (-not (Test-Path $buildScript)) { $instance | Export-BuildScript -Path $buildScript $shouldClean = $true } Import-Module InvokeBuild -Global Invoke-Build -Task $BuildType -File $buildScript -BuildInfo $instance } catch { throw } finally { if ($shouldClean) { Remove-Item $buildScript } } } } function InitializeModule { # Fill the build task cache. This makes the module immune to source file deletion once the cache is filled (when building itself). $null = Get-BuildTask -ListAvailable } InitializeModule |