Indented.Build.psm1
using namespace System.Diagnostics using namespace System.IO using namespace System.Management.Automation using namespace System.Management.Automation.Language using namespace System.Reflection using namespace System.Security.Principal [Flags()] enum BuildType { Setup = 1 Build = 2 Test = 4 Release = 8 Publish = 16 } enum ReleaseType { Build Minor Major } class BuildTask { [String]$Name [BuildType]$Stage [ScriptBlock]$ValidWhen = { $true } [Int32]$Order = 1024 [ScriptBlock]$Implementation BuildTask([String]$name, [BuildType]$stage) { $this.Name = $name $this.Stage = $stage } } [System.Diagnostics.CodeAnalysis.SuppressMessage('PSUseDeclaredVarsMoreThanAssigments', '')] class BuildInfo { # The name of the module being built. [String]$ModuleName # The build steps. [BuildType]$BuildType # The release type. [ReleaseType]$ReleaseType # The version which will be created. [Version]$Version # The tasks which will be executed during this build. [BuildTask[]]$BuildTask # The root of this repository. [DirectoryInfo]$ProjectRoot # The path to the module source [DirectoryInfo]$Source # The package generated by the build process. [DirectoryInfo]$Package # An output directory which stores files created by tools like Pester. [DirectoryInfo]$Output # The manifest associated with the package. [FileInfo]$ReleaseManifest # The root module associated with the package. [FileInfo]$ReleaseRootModule # Acceptable code coverage threshold. [Double]$CodeCoverageThreshold = 0.9 # Whether or not the current user has an administrator token [Boolean]$IsAdministrator = ([WindowsPrincipal][WindowsIdentity]::GetCurrent()).IsInRole([WindowsBuiltInRole]'Administrator') # Constructors # Supports testing hidden BuildInfo() { } BuildInfo($BuildType, $ReleaseType) { $this.BuildType = $BuildType $this.ReleaseType = $ReleaseType if ($this.ProjectRoot = (git rev-parse --show-toplevel 2> $null)) { # Converts / into \ $this.ProjectRoot = $this.ProjectRoot.FullName } else { throw (New-Object InvalidOperationException('Unable to discover repository root')) } $this.Source = $this.GetSourcePath() $this.ModuleName = $this.GetModuleName() $this.Version = $this.GetVersion() $this.BuildTask = $this.GetBuildTask() # Paths $this.Package = Join-Path $this.ProjectRoot $this.Version $this.Output = Join-Path $this.ProjectRoot 'output' if ($this.ProjectRoot.Name -ne $this.ModuleName) { $this.Package = [Path]::Combine($this.ProjectRoot, 'build', $this.ModuleName, $this.Version) $this.Output = [Path]::Combine($this.ProjectRoot, 'build', 'output', $this.ModuleName) } $this.ReleaseManifest = Join-Path $this.Package ('{0}.psd1' -f $this.ModuleName) $this.ReleaseRootModule = Join-Path $this.Package ('{0}.psm1' -f $this.ModuleName) } # Private methods hidden [BuildTask[]] GetBuildTask() { $buildInfo = $this return Get-BuildTask | Where-Object { $BuildType -band $_.Stage -and $_.ValidWhen.Invoke() } | Sort-Object Stage, Order } hidden [String] GetModuleName() { if ($this.Source.Name -eq 'source') { return $this.Source.Parent.Parent.GetDirectories($this.Source.Parent.Name).Name } else { return $this.Source.Parent.GetDirectories($this.Source.Name).Name } } hidden [String] GetSourcePath() { # Valid source paths: # ProjectRoot\source # ProjectRoot\ModuleName # ProjectRoot\ModuleName\source if (Test-Path (Join-Path $this.ProjectRoot 'source')) { return Join-Path $this.ProjectRoot 'source' } elseif (Test-Path 'source') { return Join-Path $pwd 'source' } elseif ((Split-Path $pwd -Leaf) -eq 'source') { return $pwd } elseif ((Test-Path '*.psd1') -and ((Get-Item '*.psd1').BaseName -eq (Get-Item $pwd).Name)) { return $pwd } elseif (Test-Path (Join-Path $this.ProjectRoot $this.ProjectRoot.Name)) { return Join-Path $this.ProjectRoot $this.ProjectRoot.Name } throw 'Unable to determine the source path' } hidden [Version] GetVersion() { # Prefer to use version numbers from git. $packageVersion = [Version]'1.0.0.0' [String]$gitVersion = (git describe --tags 2> $null) -replace '^v' if ([Version]::TryParse($gitVersion, [Ref]$packageVersion)) { return $this.IncrementVersion($packageVersion) } # Fall back on version numbers in the manifest. $sourceManifest = Join-Path $this.Source ('{0}.psd1' -f $this.ModuleName) if (Test-Path $sourceManifest) { $manifestVersionString = Get-Metadata -Path $sourceManifest -PropertyName ModuleVersion $manifestVersion = [Version]'0.0.0.0' if ([Version]::TryParse($manifestVersionString, [Ref]$manifestVersion)) { return $this.IncrementVersion($manifestVersion) } } return $packageVersion } hidden [Version] IncrementVersion($version) { $ctorArgs = switch ($this.ReleaseType) { 'Major' { ($version.Major + 1), 0, 0, 0 } 'Minor' { $version.Major, ($version.Minor + 1), 0, 0 } 'Build' { $version.Major, $version.Minor, ($version.Build + 1), 0 } } return New-Object Version($ctorArgs) } } function BuildTask { [OutputType('BuildTask')] param ( [Parameter(Mandatory = $true)] [String]$Name, [Parameter(Mandatory = $true)] [BuildType]$Stage, [Parameter(Mandatory = $true)] [Hashtable]$Properties ) $buildTask = New-Object BuildTask($Name, $Stage) $Properties.Keys | ForEach-Object { $buildTask.$_ = $Properties.$_ } return $buildTask } function 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. # .PARAMETER Path # A valid metadata file or string containing the metadata. # .PARAMETER PropertyName # The property to enable. # .INPUTS # System.String # .OUTPUTS # System.Boolean # .NOTES # Change log: # 04/08/2016 - Chris Dent - Created. [CmdletBinding()] [OutputType([Boolean])] param ( [Parameter(ValueFromPipelineByPropertyName = $true, Position = 0)] [ValidateScript( { Test-Path $_ -PathType Leaf } )] [Alias("PSPath")] [String]$Path, [String]$PropertyName ) process { # 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 [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 = [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 } catch { return $false } return $true } elseif ($existingValue.Count -eq 0) { # Item not found Write-Verbose "Can't find disabled property '$PropertyName' in $Path" return $false } else { # Ambiguous match Write-Verbose "Found more than one '$PropertyName' in $Path" return $false } } } function Get-BuildInfo { [CmdletBinding()] [OutputType('BuildInfo')] param ( [BuildType]$BuildType = 'Setup, Build, Test', [ReleaseType]$ReleaseType = 'Build' ) New-Object BuildInfo($BuildType, $ReleaseType) } function Get-BuildTask { [CmdletBinding()] [OutputType('BuildTask')] param ( $Name = '*' ) if (-not $Name.EndsWith('.ps1') -and -not $Name.EndsWith('*')) { $Name += '.ps1' } if ((Split-Path $psscriptroot -Leaf) -eq 'public') { $path = Join-Path $psscriptroot '..\task' } else { $path = Join-Path $psscriptroot 'task' } Get-ChildItem $path -File -Filter $Name -Recurse | ForEach-Object { . $_.FullName } } function Get-FunctionInfo { # .SYNOPSIS # Get an instance of FunctionInfo. # .DESCRIPTION # FuncitonInfo does not present a public constructor. This function calls an internal / private constructor on FunctionInfo to create a description of a function from a script block or file containing one or more functions. # .PARAMETER IncludeNested # By default functions nested inside other functions are ignored. Setting this parameter will allow nested functions to be discovered. # .PARAMETER Path # The path to a file containing one or more functions. # .PARAMETER ScriptBlock # A script block containing one or more functions. # .INPUTS # System.String # System.Management.Automation.ScriptBlock # .OUTPUTS # System.Management.Automation.FunctionInfo # .EXAMPLE # Get-ChildItem -Filter *.psm1 | Get-FunctionInfo # # Get all functions declared within the *.psm1 file and construct FunctionInfo. # .EXAMPLE # Get-ChildItem C:\Scripts -Filter *.ps1 -Recurse | Get-FunctionInfo # # Get all functions declared in all ps1 files in C:\Scripts. # .NOTES # Change log: # 10/12/2015 - Chris Dent - Improved error handling. # 28/10/2015 - Chris Dent - Created. [CmdletBinding(DefaultParameterSetName = 'FromPath')] [OutputType([System.Management.Automation.FunctionInfo])] param ( [Parameter(Position = 1, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'FromPath')] [Alias('FullName')] [String]$Path, [Parameter(ParameterSetName = 'FromScriptBlock')] [ValidateNotNullOrEmpty()] [ScriptBlock]$ScriptBlock, [Switch]$IncludeNested ) begin { $executionContextType = [PowerShell].Assembly.GetType('System.Management.Automation.ExecutionContext') $constructor = [FunctionInfo].GetConstructor( [BindingFlags]'NonPublic, Instance', $null, [CallingConventions]'Standard, HasThis', ([String], [ScriptBlock], $ExecutionContextType), $null ) } process { if ($pscmdlet.ParameterSetName -eq 'FromPath') { try { $scriptBlock = [ScriptBlock]::Create((Get-Content $Path -Raw)) } catch { $ErrorRecord = @{ Exception = $_.Exception.InnerException ErrorId = 'InvalidScriptBlock' Category = 'OperationStopped' } Write-Error @ErrorRecord } } if ($scriptBlock) { $scriptBlock.Ast.FindAll( { param( $ast ) $ast -is [FunctionDefinitionAst] }, $IncludeNested ) | ForEach-Object { try { $internalScriptBlock = $_.Body.GetScriptBlock() } catch { Write-Debug $_.Exception.Message } if ($internalScriptBlock) { $constructor.Invoke(([String]$_.Name, $internalScriptBlock, $null )) } } } } } function Invoke-BuildTask { # .SYNOPSIS # Invoke a build step. # .DESCRIPTION # An output display wrapper to show progress through a build. # .INPUTS # System.String # .OUTPUTS # System.Object # .NOTES # Change log: # 01/02/2017 - Chris Dent - Added help. [CmdletBinding()] [OutputType([PSObject])] param ( [Parameter(ValueFromPipeline = $true)] [BuildTask]$BuildTask, [BuildInfo]$BuildInfo, [Ref]$TaskInfo ) begin { $stopWatch = New-Object StopWatch } process { $progressParams = @{ Activity = 'Executing {0}' -f $BuildTask.Name Id = 2 ParentId = 1 } Write-Progress @progressParams $TaskInfo.Value = [PSCustomObject]@{ Name = $BuildTask.Name Result = 'Success' StartTime = [DateTime]::Now TimeTaken = $null Errors = $null } $messageColour = 'Green' $stopWatch = New-Object System.Diagnostics.StopWatch $stopWatch.Start() try { & $BuildTask.Implementation } catch { $TaskInfo.Value.Result = 'Failed' $TaskInfo.Value.Errors = $_ $messageColour = 'Red' } $stopWatch.Stop() $TaskInfo.Value.TimeTaken = $stopWatch.Elapsed if (-not $Quiet) { Write-Message $BuildTask.Name.PadRight(30) -ForegroundColor Cyan -NoNewline Write-Message -ForegroundColor $messageColour -Object $taskInfo.Value.Result.PadRight(10) -NoNewline Write-Message $taskInfo.Value.StartTime.ToString('t').PadRight(10) -ForegroundColor Gray -NoNewLine Write-Message $taskInfo.Value.TimeTaken -ForegroundColor Gray } } } function Start-Build { [CmdletBinding()] [OutputType([PSObject])] param ( [BuildType]$BuildType = 'Setup, Build, Test', [ValidateSet('Build', 'Minor', 'Major')] [ReleaseType]$ReleaseType = 'Build', [Switch]$PassThru, [Switch]$Quiet ) try { $null = $psboundparameters.Remove('PassThru') $null = $psboundparameters.Remove('Quiet') $buildInfo = Get-BuildInfo @psboundparameters $progressParams = @{ Activity = 'Building {0} ({1})' -f $buildInfo.ModuleName, $buildInfo.Version Id = 1 } Write-Progress @progressParams Write-Message ('Building {0} ({1})' -f $buildInfo.ModuleName, $buildInfo.Version) -Quiet:$Quiet.ToBool() -WithPadding foreach ($task in $buildInfo.BuildTask) { $taskInfo = New-Object PSObject Invoke-BuildTask $task -BuildInfo $BuildInfo -TaskInfo ([Ref]$taskInfo) if ($PassThru) { $taskInfo } if ($taskInfo.Result -ne 'Success') { throw $taskInfo.Errors } } Write-Message "Build succeeded!" -ForegroundColor Green -Quiet:$Quiet.ToBool() -WithPadding $lastexitcode = 0 } catch { Write-Message 'Build Failed!' -ForegroundColor Red -Quiet:$Quiet.ToBool() -WithPadding $lastexitcode = 1 # Catches unexpected errors, rethrows errors raised while executing steps. throw } } function Test-CIServer { <# .SYNOPSIS Attempts to determine if the host executing a build is a CI server. .DESCRIPTION Attempts to determine if the host executing a build is a CI server. State is typically evaluated by inspecting environment variables specific to each CI server type. .NOTES Change log: 20/04/2017 - Chris Dent - Created. #> [CmdletBinding()] [OutputType([System.Boolean])] param ( ) if ($env:APPVEYOR -eq $true) { return $true } if ($env:JENKINS_URL) { return $true } return $false } function Write-Message { # .SYNOPSIS # Writes a message to the console. # .DESCRIPTION # Writes a message to the console. [System.Diagnostics.CodeAnalysis.SuppressMessage('PSAvoidUsingWriteHost', '')] [CmdletBinding()] [OutputType([Void])] param ( [String]$Object, [ConsoleColor]$ForegroundColor, [Switch]$NoNewLine, [Switch]$Quiet, [Switch]$WithPadding ) $null = $psboundparameters.Remove('Quiet') $null = $psboundparameters.Remove('WithPadding') if (-not $Quiet) { if ($WithPadding) { Write-Host } Write-Host @psboundparameters if ($WithPadding) { Write-Host } } } |