diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e1994b..71d6435 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -86,6 +86,16 @@ and this project adheres to [Semantic Versioning](http://semver.org/). gating at all. The comparison is now strictly more permissive than before, so a build that passed with a coverage threshold set still passes. +- [**#167**](https://github.com/psake/PowerShellBuild/issues/167) + Builds no longer hang when the HEAD commit message is large. `BuildHelpers` + populates `$env:BHCommitMessage` by running `git log` and waiting for git to + exit before reading its output, which deadlocks once git writes more than the + pipe buffer holds — git waits for the pipe to drain, `BuildHelpers` waits for + git, and the build stops with no output and no timeout. Windows has the + smaller buffer, so it hung there first. `Initialize-PSBuild` and + `build.properties.ps1` now read the commit message directly and hand it to + `BuildHelpers` through the environment, so the git call that hangs never runs. + ## [0.8.2] 2026-07-08 ### Fixed diff --git a/PowerShellBuild/Private/Invoke-SetBuildEnvironment.ps1 b/PowerShellBuild/Private/Invoke-SetBuildEnvironment.ps1 new file mode 100644 index 0000000..1e9b190 --- /dev/null +++ b/PowerShellBuild/Private/Invoke-SetBuildEnvironment.ps1 @@ -0,0 +1,156 @@ +function Invoke-SetBuildEnvironment { + <# + .SYNOPSIS + Calls BuildHelpers\Set-BuildEnvironment without tripping its Invoke-Git deadlock. + .DESCRIPTION + BuildHelpers' Invoke-Git redirects git's output streams and then calls WaitForExit() + before reading them. When git writes more than the pipe buffer holds, git blocks waiting + for the pipe to drain while BuildHelpers blocks waiting for git to exit, and the build + hangs with no output and no timeout. The payload large enough to trigger it is the HEAD + commit message, which Get-BuildVariable reads with 'git log --format=%B -n 1'. Windows + has the smaller pipe buffer, so it hangs there first. + + Get-BuildVariable decides where the commit message comes from with a switch over the + environment variable names it recognises. Some branches read a message straight out of a + variable; the rest run git against a commit SHA held in a variable. This function makes + the first kind win: + + 1. The SHA variables whose branches shell out to git are removed for the duration of the + call, so none of those branches can be selected. The switch runs over an unordered + hashtable of variables, so removing them is the only way to make the choice + deterministic -- setting a competing variable is a coin toss. + 2. The commit message is read here instead. PowerShell drains the pipe while the process + writes, so reading it cannot deadlock. + 3. That message is published through the Azure Pipelines variable BuildHelpers consumes + verbatim, and removed again afterwards. + + Nothing else keys off the borrowed variable: the build system, branch, build root, and + build number are each detected from different variables. The suppressed SHA variables are + also used to report the commit hash, so the hash BuildHelpers derives from HEAD is + replaced afterwards with the value the build system supplied. + + See psake/PowerShellBuild#167. The defect is upstream in BuildHelpers, whose last release + (2.0.16) predates this workaround by five years. + + This file is dot-sourced by build.properties.ps1 and by this repository's own build.ps1 + as well as being loaded with the module, so it must stay self-contained: no localized + strings and no calls to other PowerShellBuild functions. + .PARAMETER Parameter + Parameters to splat onto Set-BuildEnvironment, for example @{ BuildOutput = $path; + Force = $true }. + .PARAMETER Path + Repository to read the commit message from. Defaults to the current location, which is + what Set-BuildEnvironment itself inspects when no path is supplied. + .EXAMPLE + PS> Invoke-SetBuildEnvironment -Parameter @{ Force = $true } + + Populate the BH* environment variables for the current location. + #> + [CmdletBinding()] + param( + [hashtable] + $Parameter = @{}, + + [string] + $Path = $PWD.Path + ) + + # Every variable whose Get-BuildVariable branch runs 'git log --format=%B -n 1 '. + $shaVariableUsingGit = @( + 'CI_COMMIT_SHA' # GitLab CI 9.0+ + 'CI_BUILD_REF' # GitLab CI 8.x + 'GIT_COMMIT' # Jenkins + 'BUILD_SOURCEVERSION' # Azure Pipelines classic release + 'BUILD_VCS_NUMBER' # TeamCity + 'BAMBOO_REPOSITORY_REVISION_NUMBER' # Bamboo + 'GITHUB_SHA' # GitHub Actions + ) + $azureCommitMessageVariable = 'Env:BUILD_SOURCEVERSIONMESSAGE' + + $suppressedVariable = @{} + foreach ($name in $shaVariableUsingGit) { + $variablePath = "Env:$name" + if (Test-Path -Path $variablePath) { + $suppressedVariable[$name] = (Get-Item -Path $variablePath).Value + Remove-Item -Path $variablePath + } + } + + # A build system that publishes the message itself already avoids the git call. Only read the + # repository when nothing supplied one. + $ownCommitMessage = $null + if (-not (Test-Path -Path $azureCommitMessageVariable)) { + $ownCommitMessage = Get-HeadCommitMessage -Path $Path + } + + try { + if ($ownCommitMessage) { + Set-Item -Path $azureCommitMessageVariable -Value $ownCommitMessage + } + + BuildHelpers\Set-BuildEnvironment @Parameter + + if ($ownCommitMessage) { + # The Azure Pipelines branch joins the message onto a single line. Restore the form + # the git branch produces so the variable looks the way it always has. + $env:BHCommitMessage = $ownCommitMessage + } + + # With the SHA variables hidden, BuildHelpers falls back to HEAD for the commit hash. + # That is the same commit every build system checks out, but report what the build system + # actually said. Only one CI system's variables are ever present at once; if that ever + # stops being true, leave the value BuildHelpers derived rather than guess between them. + if ($suppressedVariable.Count -eq 1) { + $env:BHCommitHash = $suppressedVariable.Values | Select-Object -First 1 + } + } finally { + if ($ownCommitMessage) { + Remove-Item -Path $azureCommitMessageVariable -ErrorAction SilentlyContinue + } + foreach ($name in $suppressedVariable.Keys) { + Set-Item -Path "Env:$name" -Value $suppressedVariable[$name] + } + } +} + +function Get-HeadCommitMessage { + <# + .SYNOPSIS + Reads the HEAD commit message the way BuildHelpers would, without the deadlock. + .DESCRIPTION + Returns the HEAD commit message normalized exactly as Get-BuildVariable normalizes it: + blank lines dropped, each remaining line trimmed, joined with newlines. Returns nothing + when git is unavailable, the path is not a repository, or the repository has no commits, + which are the same conditions under which BuildHelpers skips its own git call. + .PARAMETER Path + Repository to read from. + .EXAMPLE + PS> Get-HeadCommitMessage -Path $PWD.Path + + Return the current commit message. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [string] + $Path = $PWD.Path + ) + + $gitCommand = Get-Command -Name 'git' -CommandType 'Application' -ErrorAction 'SilentlyContinue' + if (-not $gitCommand) { + return + } + + # Matches the check BuildHelpers makes before using git. A worktree's .git is a file rather + # than a directory, which Test-Path accepts either way. + if (-not (Test-Path -Path ([IO.Path]::Combine($Path, '.git')))) { + return + } + + $messageLines = & $gitCommand[0].Path -C $Path log --format=%B -n 1 2>$null + if ($LASTEXITCODE -ne 0 -or -not $messageLines) { + return + } + + ($messageLines.Where({ $_ }).ForEach({ $_.Trim() })) -join "`n" +} diff --git a/PowerShellBuild/Public/Initialize-PSBuild.ps1 b/PowerShellBuild/Public/Initialize-PSBuild.ps1 index c45dfb7..e4b1a7c 100644 --- a/PowerShellBuild/Public/Initialize-PSBuild.ps1 +++ b/PowerShellBuild/Public/Initialize-PSBuild.ps1 @@ -45,8 +45,11 @@ function Initialize-PSBuild { $params = @{ BuildOutput = $BuildEnvironment.Build.ModuleOutDir + Force = $true } - Set-BuildEnvironment @params -Force + # Wrapped rather than called directly: BuildHelpers hangs on a large HEAD commit message. + # See Invoke-SetBuildEnvironment and psake/PowerShellBuild#167. + Invoke-SetBuildEnvironment -Parameter $params Write-Host $LocalizedData.BuildSystemDetails -ForegroundColor 'Yellow' $psVersion = $PSVersionTable.PSVersion.ToString() diff --git a/PowerShellBuild/build.properties.ps1 b/PowerShellBuild/build.properties.ps1 index 541cda9..fca0015 100644 --- a/PowerShellBuild/build.properties.ps1 +++ b/PowerShellBuild/build.properties.ps1 @@ -1,5 +1,9 @@ # spell-checker:ignore PSGALLERY BHPS MAML -BuildHelpers\Set-BuildEnvironment -Force +# Dot-sourced rather than calling BuildHelpers directly: Set-BuildEnvironment hangs on a large +# HEAD commit message. This file runs before any task, so the hang would happen before a +# consumer's build produced a single line of output. See psake/PowerShellBuild#167. +. ([IO.Path]::Combine($PSScriptRoot, 'Private', 'Invoke-SetBuildEnvironment.ps1')) +Invoke-SetBuildEnvironment -Parameter @{ Force = $true } $outDir = [IO.Path]::Combine($env:BHProjectPath, 'Output') $moduleVersion = (Import-PowerShellDataFile -Path $env:BHPSModuleManifest).ModuleVersion diff --git a/build.ps1 b/build.ps1 index 4237efb..048364e 100644 --- a/build.ps1 +++ b/build.ps1 @@ -53,7 +53,11 @@ if ($PSCmdlet.ParameterSetName -eq 'Help') { Get-PSakeScriptTasks -buildFile $psakeFile | Format-Table -Property Name, Description, Alias, DependsOn } else { - Set-BuildEnvironment -Force + # Dot-sourced from the module source rather than calling BuildHelpers directly: + # Set-BuildEnvironment hangs on a large HEAD commit message, which stalls this build before + # psake starts. See psake/PowerShellBuild#167. + . ([IO.Path]::Combine($PSScriptRoot, 'PowerShellBuild', 'Private', 'Invoke-SetBuildEnvironment.ps1')) + Invoke-SetBuildEnvironment -Parameter @{ Force = $true } $parameters = @{} if ($PSGalleryApiKey) { $parameters['galleryApiKey'] = $PSGalleryApiKey diff --git a/tests/Invoke-SetBuildEnvironment.tests.ps1 b/tests/Invoke-SetBuildEnvironment.tests.ps1 new file mode 100644 index 0000000..39ddd21 --- /dev/null +++ b/tests/Invoke-SetBuildEnvironment.tests.ps1 @@ -0,0 +1,244 @@ +# Regression tests for the BuildHelpers Invoke-Git deadlock (psake/PowerShellBuild#167). +# +# BuildHelpers' Invoke-Git waits for git to exit before reading its redirected output, so a commit +# message larger than the pipe buffer deadlocks: git blocks on the full pipe, BuildHelpers blocks +# on the process. Without the workaround these tests do not fail, they hang forever, so every call +# runs in a separate process that is killed if it overruns -- a run that does not finish is the +# failure. +# +# The child must be a process rather than a Start-Job: Invoke-Git hangs inside a background job +# whatever the output size, so a job-based harness would hang even with the workaround in place. +# +# The child also clears the build system variables it inherits before applying the ones a test +# asks for. Without that the results depend on where the suite runs: on a developer's machine +# there is no build system, while in CI the runner's own variables decide which branch of +# Get-BuildVariable is taken. +# +# The repository fixture is generated into $TestDrive at runtime, never checked in. + +BeforeDiscovery { + # -Skip is evaluated during discovery, so this cannot be read from the BeforeAll below. + $script:gitAvailable = [bool](Get-Command -Name 'git' -CommandType 'Application' -ErrorAction 'SilentlyContinue') +} + +Describe 'Invoke-SetBuildEnvironment' { + + BeforeAll { + $script:moduleRoot = Split-Path -Path $PSScriptRoot -Parent + $script:builtModulePath = [IO.Path]::Combine($script:moduleRoot, 'Output', 'PowerShellBuild') + $script:gitAvailable = [bool](Get-Command -Name 'git' -CommandType 'Application' -ErrorAction 'SilentlyContinue') + + # A repository whose HEAD commit message is far larger than any platform's pipe buffer. + # 8 KB is roughly a third of what a squashed pull request body reaches in this repository. + $script:repositoryPath = Join-Path -Path $TestDrive -ChildPath 'BigCommitMessage' + $script:commitSubject = 'subject line of an oversized commit message' + $script:commitBodyLine = 'body line that exists only to push this message past the pipe buffer' + $script:commitMessageLineCount = 120 + $script:expectedCommitMessage = (@($script:commitSubject) + (1..$script:commitMessageLineCount).ForEach({ $script:commitBodyLine })) -join "`n" + $script:commitHash = $null + + if ($script:gitAvailable) { + $manifestDirectory = Join-Path -Path $script:repositoryPath -ChildPath 'BigCommitMessage' + New-Item -Path $manifestDirectory -ItemType 'Directory' -Force > $null + Set-Content -Path (Join-Path -Path $manifestDirectory -ChildPath 'BigCommitMessage.psm1') -Value '' + $newModuleManifestParameters = @{ + Path = Join-Path -Path $manifestDirectory -ChildPath 'BigCommitMessage.psd1' + RootModule = 'BigCommitMessage.psm1' + ModuleVersion = '1.0.0' + } + New-ModuleManifest @newModuleManifestParameters + + $messageFile = Join-Path -Path $TestDrive -ChildPath 'commit-message.txt' + $messageLines = @($script:commitSubject, '') + (1..$script:commitMessageLineCount).ForEach({ $script:commitBodyLine }) + Set-Content -Path $messageFile -Value $messageLines + + # BuildHelpers falls back to the origin remote when it cannot resolve a project name, + # so the fixture declares one. It is never contacted. + Push-Location -LiteralPath $script:repositoryPath + try { + git init --initial-branch 'main' . *> $null + git config user.email 'fixture@example.invalid' *> $null + git config user.name 'PowerShellBuild fixture' *> $null + git remote add origin 'https://example.invalid/BigCommitMessage.git' *> $null + git add --all *> $null + git commit --file $messageFile *> $null + $script:commitHash = (git rev-parse HEAD).Trim() + } finally { + Pop-Location + } + } + + # Script run by each child process. Everything it needs arrives in one JSON file, which + # keeps quoting out of the argument list, and it reports back the same way. + $script:runnerPath = Join-Path -Path $TestDrive -ChildPath 'Invoke-SetBuildEnvironmentRunner.ps1' + Set-Content -Path $script:runnerPath -Value @' +param( + [string]$RequestPath +) + +$request = Get-Content -Path $RequestPath -Raw | ConvertFrom-Json + +# Start from a known environment rather than whatever build system is running the suite. +$buildSystemVariablePattern = '^(GITHUB_|CI_|CI$|BUILD_|SYSTEM_|TF_BUILD|APPVEYOR|TRAVIS|JENKINS_URL|TEAMCITY_|BAMBOO|GOCD_|GITLAB_CI|GO_REVISION|WORKSPACE$|GIT_COMMIT$|GIT_BRANCH$)' +Get-ChildItem -Path 'Env:' | + Where-Object { $_.Name -match $buildSystemVariablePattern } | + ForEach-Object { Remove-Item -Path "Env:$($_.Name)" -ErrorAction SilentlyContinue } + +foreach ($property in $request.PresetEnvironment.PSObject.Properties) { + Set-Item -Path "Env:$($property.Name)" -Value $property.Value +} + +Import-Module -Name $request.ModulePath -Force -ErrorAction Stop +Set-Location -LiteralPath $request.RepositoryPath + +$module = Get-Module -Name 'PowerShellBuild' +& $module { Invoke-SetBuildEnvironment -Parameter @{ Force = $true } } + +$result = [PSCustomObject]@{ + CommitMessage = $env:BHCommitMessage + CommitHash = $env:BHCommitHash + BuildSystem = $env:BHBuildSystem + ProjectName = $env:BHProjectName + BranchName = $env:BHBranchName + AzureCommitMessage = $env:BUILD_SOURCEVERSIONMESSAGE + AzureVariablePresent = [bool](Test-Path -Path 'Env:BUILD_SOURCEVERSIONMESSAGE') + GitHubSha = $env:GITHUB_SHA + GitHubShaPresent = [bool](Test-Path -Path 'Env:GITHUB_SHA') +} +$result | ConvertTo-Json | Set-Content -Path $request.ResultPath +'@ + + # Runs the runner against the fixture and returns what it reported, or $null when it had + # to be killed -- which is what the deadlock looks like. + function script:Invoke-SetBuildEnvironmentProcess { + param( + [hashtable]$PresetEnvironment = @{}, + [int]$TimeoutSecond = 120 + ) + + $identifier = [Guid]::NewGuid().ToString('N') + $resultPath = Join-Path -Path $TestDrive -ChildPath "result-$identifier.json" + $requestPath = Join-Path -Path $TestDrive -ChildPath "request-$identifier.json" + $request = [PSCustomObject]@{ + ModulePath = $script:builtModulePath + RepositoryPath = $script:repositoryPath + ResultPath = $resultPath + PresetEnvironment = $PresetEnvironment + } + $request | ConvertTo-Json | Set-Content -Path $requestPath + + # The same host the tests are running under, so the Windows PowerShell 5.1 leg + # exercises Windows PowerShell. + $startProcessParameters = @{ + FilePath = (Get-Process -Id $PID).Path + ArgumentList = @('-NoProfile', '-File', "`"$script:runnerPath`"", "`"$requestPath`"") + PassThru = $true + NoNewWindow = $true + } + $process = Start-Process @startProcessParameters + + if (-not $process.WaitForExit($TimeoutSecond * 1000)) { + $process.Kill() + return + } + if (-not (Test-Path -Path $resultPath)) { + return + } + Get-Content -Path $resultPath -Raw | ConvertFrom-Json + } + } + + Context 'with a commit message larger than the pipe buffer' -Skip:(-not $script:gitAvailable) { + + BeforeAll { + $script:result = Invoke-SetBuildEnvironmentProcess + } + + It 'completes instead of hanging' { + # Regression: #167. A $null result means the process had to be killed. + $script:result | Should -Not -BeNullOrEmpty + } + + It 'reports the whole commit message' { + $script:result.CommitMessage | Should -Be $script:expectedCommitMessage + } + + It 'still detects the build system correctly' { + # The workaround borrows an Azure Pipelines variable; borrowing it must not make + # BuildHelpers believe this is an Azure Pipelines build. + $script:result.BuildSystem | Should -Be 'Unknown' + } + + It 'still populates the other build variables' { + $script:result.ProjectName | Should -Be 'BigCommitMessage' + $script:result.BranchName | Should -Be 'main' + $script:result.CommitHash | Should -Be $script:commitHash + } + + It 'removes the borrowed environment variable' { + $script:result.AzureVariablePresent | Should -BeFalse + } + } + + Context 'when the build system publishes a commit SHA' -Skip:(-not $script:gitAvailable) { + + BeforeAll { + # GitHub Actions is the case that hung main: its branch of Get-BuildVariable runs git + # against $env:GITHUB_SHA, and the switch picks it out of an unordered collection, so + # supplying a message without hiding this variable is a coin toss. + $presetEnvironment = @{ + GITHUB_SHA = $script:commitHash + GITHUB_WORKFLOW = 'ci' + GITHUB_REF = 'refs/heads/main' + } + $script:actionsResult = Invoke-SetBuildEnvironmentProcess -PresetEnvironment $presetEnvironment + } + + It 'completes instead of hanging' { + # Regression: #167, the CI half. This is the combination that hung both Windows legs. + $script:actionsResult | Should -Not -BeNullOrEmpty + } + + It 'reports the whole commit message' { + $script:actionsResult.CommitMessage | Should -Be $script:expectedCommitMessage + } + + It 'still detects the build system correctly' { + $script:actionsResult.BuildSystem | Should -Be 'GitHub Actions' + } + + It 'reports the commit hash the build system supplied' { + $script:actionsResult.CommitHash | Should -Be $script:commitHash + } + + It 'restores the suppressed variable' { + $script:actionsResult.GitHubShaPresent | Should -BeTrue + $script:actionsResult.GitHubSha | Should -Be $script:commitHash + } + } + + Context 'when the environment already supplies a commit message' -Skip:(-not $script:gitAvailable) { + + BeforeAll { + $script:presetMessage = 'commit message supplied by the build system' + $presetEnvironment = @{ + BUILD_SOURCEVERSIONMESSAGE = $script:presetMessage + } + $script:presetResult = Invoke-SetBuildEnvironmentProcess -PresetEnvironment $presetEnvironment + } + + It 'completes instead of hanging' { + $script:presetResult | Should -Not -BeNullOrEmpty + } + + It 'leaves the supplied variable in place' { + # A real Azure Pipelines build owns this variable. The workaround must not delete it. + $script:presetResult.AzureVariablePresent | Should -BeTrue + $script:presetResult.AzureCommitMessage | Should -Be $script:presetMessage + } + + It 'uses the supplied message rather than reading the repository' { + $script:presetResult.CommitMessage | Should -Be $script:presetMessage + } + } +}