From c4523f455a6560286add373be6b409e2a50aaf80 Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Sat, 22 Aug 2026 15:06:47 -0400 Subject: [PATCH 1/2] fix: Stop builds hanging on a large HEAD commit message BuildHelpers populates $env:BHCommitMessage by running git log --format=%B -n 1 through Invoke-Git, which redirects git's output streams and then calls WaitForExit() before reading them. Once git writes more than the pipe buffer holds it blocks waiting for a reader while BuildHelpers blocks waiting for the process, and the build stops with no output and no timeout. Windows has the smaller buffer, so it hangs there first. Measured on this repository: a 5498-byte commit message returns, 5757 bytes hangs. Squashed pull request bodies reach that size routinely, so the merge of #162 hung both Windows CI legs on main. Get-BuildVariable only shells out for the message when it cannot read one from a known CI variable. Invoke-SetBuildEnvironment reads the message itself -- PowerShell drains the pipe while the process writes, so it cannot deadlock -- publishes it through the Azure Pipelines variable BuildHelpers consumes verbatim, calls Set-BuildEnvironment, and removes the variable again. Nothing else keys off that variable: the build system, branch, commit hash, and build number are each detected from different ones, so borrowing it does not make BuildHelpers report an Azure Pipelines build. A real pipeline that already supplies it is left alone. All three call sites are covered: Initialize-PSBuild, the shipped build.properties.ps1 that runs before any consumer task, and this repository's own build.ps1. The regression tests run each call in a child process that is killed if it overruns, because without the fix they would not fail, they would hang. A background job cannot be used: Invoke-Git deadlocks inside one whatever the output size, so a job-based harness would hang even with the fix in place. The defect is upstream in BuildHelpers, whose last release predates this by five years. Closes #167 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018TJfFJGtUJY5CFu8MRRMYt --- CHANGELOG.md | 10 + .../Private/Invoke-SetBuildEnvironment.ps1 | 117 +++++++++++ PowerShellBuild/Public/Initialize-PSBuild.ps1 | 5 +- PowerShellBuild/build.properties.ps1 | 6 +- build.ps1 | 6 +- tests/Invoke-SetBuildEnvironment.tests.ps1 | 196 ++++++++++++++++++ 6 files changed, 337 insertions(+), 3 deletions(-) create mode 100644 PowerShellBuild/Private/Invoke-SetBuildEnvironment.ps1 create mode 100644 tests/Invoke-SetBuildEnvironment.tests.ps1 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..c101b89 --- /dev/null +++ b/PowerShellBuild/Private/Invoke-SetBuildEnvironment.ps1 @@ -0,0 +1,117 @@ +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 only shells out for the commit message when it cannot read one from a + known CI variable. This function reads the message itself -- PowerShell drains the pipe + while the process writes, so it cannot deadlock -- and publishes it through the one + variable BuildHelpers consumes verbatim, so BuildHelpers never runs the git command that + hangs. The variable is removed again afterwards. + + See psake/PowerShellBuild#167. The defect is upstream in BuildHelpers, whose last release + (2.0.16) predates this workaround by several 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 + ) + + # Azure Pipelines publishes the commit message in this variable, and Get-BuildVariable reads + # it directly instead of calling git. Nothing else keys off it: the build system, branch, + # commit hash, and build number are each detected from different variables, so borrowing this + # one does not make BuildHelpers report an Azure Pipelines build. + $commitMessageVariable = 'Env:BUILD_SOURCEVERSIONMESSAGE' + + $commitMessage = $null + # A real Azure Pipelines build already supplies the variable. Leave it alone -- BuildHelpers + # will use it and never reach the git call. + if (-not (Test-Path -Path $commitMessageVariable)) { + $commitMessage = Get-HeadCommitMessage -Path $Path + } + + try { + if ($commitMessage) { + Set-Item -Path $commitMessageVariable -Value $commitMessage + } + + BuildHelpers\Set-BuildEnvironment @Parameter + + if ($commitMessage) { + # The Azure Pipelines path joins the message onto a single line. Restore the form the + # git path produces so the variable looks the same as it always has. + $env:BHCommitMessage = $commitMessage + } + } finally { + if ($commitMessage) { + Remove-Item -Path $commitMessageVariable -ErrorAction SilentlyContinue + } + } +} + +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..4876d6f --- /dev/null +++ b/tests/Invoke-SetBuildEnvironment.tests.ps1 @@ -0,0 +1,196 @@ +# 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 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 + + 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 resolves the project name from the origin remote when it cannot find a + # manifest, 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 + } finally { + Pop-Location + } + } + + # Script run by each child process. Reporting through a file keeps the assertions + # independent of anything the child writes to its own output streams. + $script:runnerPath = Join-Path -Path $TestDrive -ChildPath 'Invoke-SetBuildEnvironmentRunner.ps1' + Set-Content -Path $script:runnerPath -Value @' +param( + [string]$ModulePath, + [string]$RepositoryPath, + [string]$ResultPath, + [string]$PresetCommitMessage +) + +if ($PresetCommitMessage) { + Set-Item -Path 'Env:BUILD_SOURCEVERSIONMESSAGE' -Value $PresetCommitMessage +} + +Import-Module -Name $ModulePath -Force -ErrorAction Stop +Set-Location -LiteralPath $RepositoryPath + +$module = Get-Module -Name 'PowerShellBuild' +& $module { Invoke-SetBuildEnvironment -Parameter @{ Force = $true } } + +$result = [PSCustomObject]@{ + CommitMessage = $env:BHCommitMessage + BuildSystem = $env:BHBuildSystem + ProjectName = $env:BHProjectName + BranchName = $env:BHBranchName + AzureCommitMessage = $env:BUILD_SOURCEVERSIONMESSAGE + AzureVariablePresent = [bool](Test-Path -Path 'Env:BUILD_SOURCEVERSIONMESSAGE') +} +$result | ConvertTo-Json | Set-Content -Path $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( + [string]$PresetCommitMessage, + [int]$TimeoutSecond = 120 + ) + + $resultPath = Join-Path -Path $TestDrive -ChildPath ('result-{0}.json' -f [Guid]::NewGuid()) + # Start-Process passes the argument list as one string, so every value that could + # contain a space -- a path, or the message itself -- has to arrive quoted. + $quote = { '"{0}"' -f $args[0] } + $arguments = @( + '-NoProfile' + '-File', (& $quote $script:runnerPath) + '-ModulePath', (& $quote $script:builtModulePath) + '-RepositoryPath', (& $quote $script:repositoryPath) + '-ResultPath', (& $quote $resultPath) + ) + if ($PresetCommitMessage) { + $arguments += @('-PresetCommitMessage', (& $quote $PresetCommitMessage)) + } + + # The same host the tests are running under, so the Windows PowerShell 5.1 leg + # exercises Windows PowerShell. + $hostExecutable = (Get-Process -Id $PID).Path + $startProcessParameters = @{ + FilePath = $hostExecutable + ArgumentList = $arguments + PassThru = $true + NoNewWindow = $true + } + $process = Start-Process @startProcessParameters + + $exited = $process.WaitForExit($TimeoutSecond * 1000) + if (-not $exited) { + $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' { + $expectedMessage = (@($script:commitSubject) + (1..$script:commitMessageLineCount).ForEach({ $script:commitBodyLine })) -join "`n" + + $script:result.CommitMessage | Should -Be $expectedMessage + } + + 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' + } + + It 'removes the borrowed environment variable' { + $script:result.AzureVariablePresent | Should -BeFalse + } + } + + Context 'when the environment already supplies a commit message' -Skip:(-not $script:gitAvailable) { + + BeforeAll { + $script:presetMessage = 'commit message supplied by the build system' + $script:presetResult = Invoke-SetBuildEnvironmentProcess -PresetCommitMessage $script:presetMessage + } + + 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 + } + } +} From ea43c4d21b770b0a0fad4d1bf5af2e7a6d7dbfad Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Sat, 22 Aug 2026 15:28:48 -0400 Subject: [PATCH 2/2] fix: Hide the SHA variables that reintroduce the git deadlock The first cut supplied the commit message through the Azure Pipelines variable and assumed BuildHelpers would use it. Get-BuildVariable picks the source with a switch over an unordered collection of environment variable names, so when a build system also publishes a commit SHA -- GITHUB_SHA, CI_COMMIT_SHA, GIT_COMMIT and four others -- whichever name the collection yields first wins. That is a coin toss, and CI called it the other way: the ubuntu and macOS legs took the GITHUB_SHA branch, which runs git for the message, and reported the runner's environment instead of the fixture's. Removing those seven variables for the duration of the call makes the choice deterministic, because no branch that shells out for the message can be selected. They are restored afterwards, and because they also supply the commit hash, the hash BuildHelpers derives from HEAD is replaced with the value the build system gave. The tests now clear inherited build system variables in the child before applying the ones each case asks for, so results no longer depend on where the suite runs, and a new case covers the GitHub Actions combination that hung main. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018TJfFJGtUJY5CFu8MRRMYt --- .../Private/Invoke-SetBuildEnvironment.ps1 | 89 +++++++++---- tests/Invoke-SetBuildEnvironment.tests.ps1 | 122 ++++++++++++------ 2 files changed, 149 insertions(+), 62 deletions(-) diff --git a/PowerShellBuild/Private/Invoke-SetBuildEnvironment.ps1 b/PowerShellBuild/Private/Invoke-SetBuildEnvironment.ps1 index c101b89..1e9b190 100644 --- a/PowerShellBuild/Private/Invoke-SetBuildEnvironment.ps1 +++ b/PowerShellBuild/Private/Invoke-SetBuildEnvironment.ps1 @@ -10,14 +10,27 @@ function Invoke-SetBuildEnvironment { 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 only shells out for the commit message when it cannot read one from a - known CI variable. This function reads the message itself -- PowerShell drains the pipe - while the process writes, so it cannot deadlock -- and publishes it through the one - variable BuildHelpers consumes verbatim, so BuildHelpers never runs the git command that - hangs. The variable is removed again afterwards. + 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 several years. + (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 @@ -42,34 +55,60 @@ function Invoke-SetBuildEnvironment { $Path = $PWD.Path ) - # Azure Pipelines publishes the commit message in this variable, and Get-BuildVariable reads - # it directly instead of calling git. Nothing else keys off it: the build system, branch, - # commit hash, and build number are each detected from different variables, so borrowing this - # one does not make BuildHelpers report an Azure Pipelines build. - $commitMessageVariable = 'Env:BUILD_SOURCEVERSIONMESSAGE' - - $commitMessage = $null - # A real Azure Pipelines build already supplies the variable. Leave it alone -- BuildHelpers - # will use it and never reach the git call. - if (-not (Test-Path -Path $commitMessageVariable)) { - $commitMessage = Get-HeadCommitMessage -Path $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 ($commitMessage) { - Set-Item -Path $commitMessageVariable -Value $commitMessage + if ($ownCommitMessage) { + Set-Item -Path $azureCommitMessageVariable -Value $ownCommitMessage } BuildHelpers\Set-BuildEnvironment @Parameter - if ($commitMessage) { - # The Azure Pipelines path joins the message onto a single line. Restore the form the - # git path produces so the variable looks the same as it always has. - $env:BHCommitMessage = $commitMessage + 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 ($commitMessage) { - Remove-Item -Path $commitMessageVariable -ErrorAction SilentlyContinue + if ($ownCommitMessage) { + Remove-Item -Path $azureCommitMessageVariable -ErrorAction SilentlyContinue + } + foreach ($name in $suppressedVariable.Keys) { + Set-Item -Path "Env:$name" -Value $suppressedVariable[$name] } } } diff --git a/tests/Invoke-SetBuildEnvironment.tests.ps1 b/tests/Invoke-SetBuildEnvironment.tests.ps1 index 4876d6f..39ddd21 100644 --- a/tests/Invoke-SetBuildEnvironment.tests.ps1 +++ b/tests/Invoke-SetBuildEnvironment.tests.ps1 @@ -9,6 +9,11 @@ # 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 { @@ -29,6 +34,8 @@ Describe 'Invoke-SetBuildEnvironment' { $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' @@ -45,8 +52,8 @@ Describe 'Invoke-SetBuildEnvironment' { $messageLines = @($script:commitSubject, '') + (1..$script:commitMessageLineCount).ForEach({ $script:commitBodyLine }) Set-Content -Path $messageFile -Value $messageLines - # BuildHelpers resolves the project name from the origin remote when it cannot find a - # manifest, so the fixture declares one. It is never contacted. + # 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 @@ -55,83 +62,85 @@ Describe 'Invoke-SetBuildEnvironment' { 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. Reporting through a file keeps the assertions - # independent of anything the child writes to its own output streams. + # 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]$ModulePath, - [string]$RepositoryPath, - [string]$ResultPath, - [string]$PresetCommitMessage + [string]$RequestPath ) -if ($PresetCommitMessage) { - Set-Item -Path 'Env:BUILD_SOURCEVERSIONMESSAGE' -Value $PresetCommitMessage +$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 $ModulePath -Force -ErrorAction Stop -Set-Location -LiteralPath $RepositoryPath +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 $ResultPath +$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( - [string]$PresetCommitMessage, + [hashtable]$PresetEnvironment = @{}, [int]$TimeoutSecond = 120 ) - $resultPath = Join-Path -Path $TestDrive -ChildPath ('result-{0}.json' -f [Guid]::NewGuid()) - # Start-Process passes the argument list as one string, so every value that could - # contain a space -- a path, or the message itself -- has to arrive quoted. - $quote = { '"{0}"' -f $args[0] } - $arguments = @( - '-NoProfile' - '-File', (& $quote $script:runnerPath) - '-ModulePath', (& $quote $script:builtModulePath) - '-RepositoryPath', (& $quote $script:repositoryPath) - '-ResultPath', (& $quote $resultPath) - ) - if ($PresetCommitMessage) { - $arguments += @('-PresetCommitMessage', (& $quote $PresetCommitMessage)) + $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. - $hostExecutable = (Get-Process -Id $PID).Path $startProcessParameters = @{ - FilePath = $hostExecutable - ArgumentList = $arguments + FilePath = (Get-Process -Id $PID).Path + ArgumentList = @('-NoProfile', '-File', "`"$script:runnerPath`"", "`"$requestPath`"") PassThru = $true NoNewWindow = $true } $process = Start-Process @startProcessParameters - $exited = $process.WaitForExit($TimeoutSecond * 1000) - if (-not $exited) { + if (-not $process.WaitForExit($TimeoutSecond * 1000)) { $process.Kill() return } - if (-not (Test-Path -Path $resultPath)) { return } @@ -151,9 +160,7 @@ $result | ConvertTo-Json | Set-Content -Path $ResultPath } It 'reports the whole commit message' { - $expectedMessage = (@($script:commitSubject) + (1..$script:commitMessageLineCount).ForEach({ $script:commitBodyLine })) -join "`n" - - $script:result.CommitMessage | Should -Be $expectedMessage + $script:result.CommitMessage | Should -Be $script:expectedCommitMessage } It 'still detects the build system correctly' { @@ -165,6 +172,7 @@ $result | ConvertTo-Json | Set-Content -Path $ResultPath 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' { @@ -172,11 +180,51 @@ $result | ConvertTo-Json | Set-Content -Path $ResultPath } } + 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' - $script:presetResult = Invoke-SetBuildEnvironmentProcess -PresetCommitMessage $script:presetMessage + $presetEnvironment = @{ + BUILD_SOURCEVERSIONMESSAGE = $script:presetMessage + } + $script:presetResult = Invoke-SetBuildEnvironmentProcess -PresetEnvironment $presetEnvironment } It 'completes instead of hanging' {