From 6c85b69a3c03feef3796a8a502660d84a3e5d5fb Mon Sep 17 00:00:00 2001 From: Stiwi Gabriel Courage Date: Wed, 29 Apr 2026 10:49:58 +0200 Subject: [PATCH 1/3] feat(#140): enhance release automation and CI support - Update `Update-NovaModuleVersion -ContinuousIntegration` to fallback to a patch bump when `HEAD` matches the latest tag - Fix PSGallery publishing on fresh CI runners by bootstrapping the PSResourceGet repository store - Add `Get-PSResourceRepositoryStoreDirectory` and `Initialize-PSGalleryRepository` functions --- CHANGELOG.md | 5 +++ README.md | 8 ++++ scripts/release/Publish-ToPSGallery.ps1 | 7 ++- scripts/release/SemanticReleaseSupport.ps1 | 2 + ...Get-PSResourceRepositoryStoreDirectory.ps1 | 7 +++ .../Initialize-PSGalleryRepository.ps1 | 14 ++++++ .../release/GetNovaVersionLabelForBump.ps1 | 7 ++- .../GetNovaVersionUpdateWorkflowContext.ps1 | 2 +- tests/CoverageGaps.ReleaseInternals.Tests.ps1 | 27 ++++++++++++ tests/NovaCommandModel.BumpAndCli.Tests.ps1 | 34 +++++++++++++++ tests/SemanticRelease.Tests.ps1 | 43 +++++++++++++++++++ 11 files changed, 153 insertions(+), 3 deletions(-) create mode 100644 scripts/release/support/Get-PSResourceRepositoryStoreDirectory.ps1 create mode 100644 scripts/release/support/Initialize-PSGalleryRepository.ps1 diff --git a/CHANGELOG.md b/CHANGELOG.md index d2f375e4..f9f786bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -89,6 +89,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - CI bump now reuses the already activated built-module command when the current session is already running from `dist/`, so publish-then-bump prerelease automation can continue in the same session without losing private helper bindings. + - `Update-NovaModuleVersion -ContinuousIntegration` now also falls back to a patch bump when `HEAD` already matches + the latest tag, so release automation can prepare the next prerelease version without requiring an extra commit. - Keep standalone `nova bump` output stable by formatting version-update results in the CLI layer instead of relying on PowerShell's default object rendering. - `nova bump --what-if` and `% run.ps1` now surface a predictable summary for previous version, new version, label, @@ -104,6 +106,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 failing during parameter binding. - Fix configuration and validation errors so empty `project.json` files and unsupported `Manifest` keys fail fast with clear messages. +- Fix semantic-release PSGallery publishing on fresh CI runners by bootstrapping the PSResourceGet repository store + before + `Publish-PSResource` runs. ### Changed diff --git a/README.md b/README.md index 11da39aa..440e4128 100644 --- a/README.md +++ b/README.md @@ -209,6 +209,9 @@ These switches keep the behavior explicit and opt-in: - `Invoke-NovaBuild -ContinuousIntegration` re-imports the freshly built module after the build succeeds - `Update-NovaModuleVersion -ContinuousIntegration` re-imports the built module before the bump workflow starts +- `Update-NovaModuleVersion -ContinuousIntegration` also falls back to a patch bump when the current `HEAD` already + matches the latest tag, so release automation can seed the next prerelease line without requiring an extra commit + first - `Publish-NovaModule -ContinuousIntegration` restores the built module after publish completes - `Invoke-NovaRelease -ContinuousIntegration` forwards that CI intent through the nested build/bump boundaries and then restores the built module again after publish @@ -595,6 +598,11 @@ Responsibilities currently covered by the release pipeline include: - creating GitHub releases - publishing to PowerShell Gallery +The semantic-release publish script also bootstraps the local PSResourceGet repository store before calling +`Publish-PSResource`, which keeps fresh GitHub Actions runners from failing when the PSGallery repository registration +file +does not exist yet. + ### Where NovaModuleTools cmdlets fit NovaModuleTools already provides strong release building blocks: diff --git a/scripts/release/Publish-ToPSGallery.ps1 b/scripts/release/Publish-ToPSGallery.ps1 index 0d580ce6..d9a12932 100644 --- a/scripts/release/Publish-ToPSGallery.ps1 +++ b/scripts/release/Publish-ToPSGallery.ps1 @@ -4,6 +4,10 @@ param( ) Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$scriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path +. (Join-Path $scriptRoot 'SemanticReleaseSupport.ps1') if ([string]::IsNullOrWhiteSpace($ApiKey)) { throw 'PSGALLERY_API environment variable is required.' @@ -13,4 +17,5 @@ if (-not (Test-Path -LiteralPath $ModulePath)) { throw "Module path not found: $ModulePath" } -Publish-PSResource -Path $ModulePath -Repository PSGallery -ApiKey $ApiKey -Verbose +Initialize-PSGalleryRepository +Publish-PSResource -Path $ModulePath -Repository PSGallery -ApiKey $ApiKey -Verbose -ErrorAction Stop diff --git a/scripts/release/SemanticReleaseSupport.ps1 b/scripts/release/SemanticReleaseSupport.ps1 index 43e66c4b..2e10af98 100644 --- a/scripts/release/SemanticReleaseSupport.ps1 +++ b/scripts/release/SemanticReleaseSupport.ps1 @@ -4,6 +4,8 @@ $script:supportRoot = Join-Path (Split-Path -Parent $MyInvocation.MyCommand.Path $script:supportFileList = @( 'Get-ReleaseDateString.ps1' 'Get-ReleaseRepositoryUrl.ps1' + 'Get-PSResourceRepositoryStoreDirectory.ps1' + 'Initialize-PSGalleryRepository.ps1' 'ConvertTo-ReleaseTagName.ps1' 'Read-JsonFile.ps1' 'Write-JsonFile.ps1' diff --git a/scripts/release/support/Get-PSResourceRepositoryStoreDirectory.ps1 b/scripts/release/support/Get-PSResourceRepositoryStoreDirectory.ps1 new file mode 100644 index 00000000..31866c49 --- /dev/null +++ b/scripts/release/support/Get-PSResourceRepositoryStoreDirectory.ps1 @@ -0,0 +1,7 @@ +function Get-PSResourceRepositoryStoreDirectory { + [CmdletBinding()] + param() + + return Join-Path $HOME '.local/share/PSResourceGet' +} + diff --git a/scripts/release/support/Initialize-PSGalleryRepository.ps1 b/scripts/release/support/Initialize-PSGalleryRepository.ps1 new file mode 100644 index 00000000..6a51ad26 --- /dev/null +++ b/scripts/release/support/Initialize-PSGalleryRepository.ps1 @@ -0,0 +1,14 @@ +function Initialize-PSGalleryRepository { + [CmdletBinding()] + param() + + $storeDirectory = Get-PSResourceRepositoryStoreDirectory + if (-not (Test-Path -LiteralPath $storeDirectory)) { + New-Item -ItemType Directory -Path $storeDirectory -Force | Out-Null + } + + if ($null -eq (Get-PSResourceRepository -Name PSGallery -ErrorAction SilentlyContinue)) { + Register-PSResourceRepository -PSGallery + } +} + diff --git a/src/private/release/GetNovaVersionLabelForBump.ps1 b/src/private/release/GetNovaVersionLabelForBump.ps1 index ae18be91..bce84ef8 100644 --- a/src/private/release/GetNovaVersionLabelForBump.ps1 +++ b/src/private/release/GetNovaVersionLabelForBump.ps1 @@ -2,7 +2,8 @@ function Get-NovaVersionLabelForBump { [CmdletBinding()] param( [Parameter(Mandatory)][string]$ProjectRoot, - [string[]]$CommitMessages = @() + [string[]]$CommitMessages = @(), + [switch]$ContinuousIntegrationRequested ) if ($CommitMessages.Count -gt 0) { @@ -18,6 +19,10 @@ function Get-NovaVersionLabelForBump { } if (-not (Test-GitRepositoryHasCommitsSinceLatestTag -ProjectRoot $ProjectRoot)) { + if ($ContinuousIntegrationRequested) { + return 'Patch' + } + Stop-NovaOperation -Message 'Cannot bump version because there are no commits since the latest tag.' -ErrorId 'Nova.Workflow.NoCommitsSinceLatestTag' -Category InvalidOperation -TargetObject $ProjectRoot } diff --git a/src/private/release/GetNovaVersionUpdateWorkflowContext.ps1 b/src/private/release/GetNovaVersionUpdateWorkflowContext.ps1 index 00f76cbb..c689392a 100644 --- a/src/private/release/GetNovaVersionUpdateWorkflowContext.ps1 +++ b/src/private/release/GetNovaVersionUpdateWorkflowContext.ps1 @@ -8,7 +8,7 @@ function Get-NovaVersionUpdateWorkflowContext { $projectInfo = Get-NovaProjectInfo -Path $ProjectRoot $commitMessages = @(Get-GitCommitMessageForVersionBump -ProjectRoot $ProjectRoot) - $label = Get-NovaVersionLabelForBump -ProjectRoot $ProjectRoot -CommitMessages $commitMessages + $label = Get-NovaVersionLabelForBump -ProjectRoot $ProjectRoot -CommitMessages $commitMessages -ContinuousIntegrationRequested:$ContinuousIntegrationRequested $versionUpdatePlan = Get-NovaVersionUpdatePlan -ProjectInfo $projectInfo -Label $label -PreviewRelease:$PreviewRelease return Get-NovaVersionUpdateWorkflowContextObject -ProjectRoot $ProjectRoot -ProjectInfo $projectInfo -CommitMessages $commitMessages -Label $label -VersionUpdatePlan $versionUpdatePlan -PreviewRelease:$PreviewRelease -ContinuousIntegrationRequested:$ContinuousIntegrationRequested diff --git a/tests/CoverageGaps.ReleaseInternals.Tests.ps1 b/tests/CoverageGaps.ReleaseInternals.Tests.ps1 index d1a1aae7..cd365836 100644 --- a/tests/CoverageGaps.ReleaseInternals.Tests.ps1 +++ b/tests/CoverageGaps.ReleaseInternals.Tests.ps1 @@ -620,6 +620,33 @@ Describe 'Coverage gaps for release and git internals' { } } + It 'Get-NovaVersionLabelForBump returns Patch in CI mode when the current head already matches the latest tag' { + InModuleScope $script:moduleName { + $projectRoot = Join-Path $TestDrive 'mocked-ci-git-state' + New-Item -ItemType Directory -Path (Join-Path $projectRoot '.git') -Force | Out-Null + + Mock Invoke-NovaGitCommand { + switch ($Arguments[0]) { + 'rev-parse' { + return [pscustomobject]@{ExitCode = 0; Output = @('.git')} + } + 'describe' { + return [pscustomobject]@{ExitCode = 0; Output = @('v1.0.0')} + } + 'rev-list' { + return [pscustomobject]@{ExitCode = 0; Output = @('0')} + } + default { + throw "Unexpected git args: $( $Arguments -join ' ' )" + } + } + } + + Get-NovaVersionLabelForBump -ProjectRoot $projectRoot -ContinuousIntegrationRequested | Should -Be 'Patch' + Assert-MockCalled Invoke-NovaGitCommand -Times 1 -ParameterFilter {$Arguments[0] -eq 'rev-list' -and $Arguments[2] -eq 'v1.0.0..HEAD'} + } + } + It 'Get-NovaVersionLabelForBump returns Patch when the repository has commits but no tags yet' { InModuleScope $script:moduleName { $projectRoot = Join-Path $TestDrive 'mocked-git-without-tags' diff --git a/tests/NovaCommandModel.BumpAndCli.Tests.ps1 b/tests/NovaCommandModel.BumpAndCli.Tests.ps1 index 3b40bee0..d28a002b 100644 --- a/tests/NovaCommandModel.BumpAndCli.Tests.ps1 +++ b/tests/NovaCommandModel.BumpAndCli.Tests.ps1 @@ -133,6 +133,10 @@ Describe 'Nova command model - bump and CLI confirmation behavior' { $result = Get-NovaVersionUpdateWorkflowContext -ProjectRoot '/tmp/project' -ContinuousIntegrationRequested $result.'ContinuousIntegrationRequested' | Should -BeTrue + Assert-MockCalled Get-NovaVersionLabelForBump -Times 1 -ParameterFilter { + $ProjectRoot -eq '/tmp/project' -and + $ContinuousIntegrationRequested + } } } @@ -524,6 +528,36 @@ Describe 'Nova command model - bump and CLI confirmation behavior' { } } + It 'Update-NovaModuleVersion -Preview -ContinuousIntegration -WhatIf still bumps from a tagged head with no new commits' -Skip:(-not [bool](Get-Command git -ErrorAction SilentlyContinue)) { + InModuleScope $script:moduleName { + $projectRoot = Join-Path $TestDrive 'tagged-head-ci-bump-project' + New-Item -ItemType Directory -Path $projectRoot -Force | Out-Null + & git -C $projectRoot init --quiet | Out-Null + & git -C $projectRoot config user.name 'NovaModuleTools Tests' | Out-Null + & git -C $projectRoot config user.email 'tests@example.invalid' | Out-Null + Set-Content -LiteralPath (Join-Path $projectRoot 'first.txt') -Value 'first' -Encoding utf8 + & git -C $projectRoot add first.txt | Out-Null + & git -C $projectRoot commit --quiet -m 'feat: initial release' | Out-Null + & git -C $projectRoot tag v2.0.0 | Out-Null + + Mock Get-NovaProjectInfo { + [pscustomobject]@{ + Version = '2.0.0' + ProjectJSON = (Join-Path $projectRoot 'project.json') + } + } + Mock Set-NovaModuleVersion {throw 'WhatIf should not write project.json'} + + $result = Update-NovaModuleVersion -Path $projectRoot -Preview -ContinuousIntegration -WhatIf + + $result.PreviousVersion | Should -Be '2.0.0' + $result.NewVersion | Should -Be '2.0.1-preview' + $result.Label | Should -Be 'Patch' + $result.CommitCount | Should -Be 0 + Assert-MockCalled Set-NovaModuleVersion -Times 0 + } + } + It 'Update-NovaModuleVersion keeps native PowerShell Confirm support for direct cmdlet usage and does not route through the CLI helper' { InModuleScope $script:moduleName { $command = Get-Command -Name 'Update-NovaModuleVersion' -CommandType Function -ErrorAction Stop diff --git a/tests/SemanticRelease.Tests.ps1 b/tests/SemanticRelease.Tests.ps1 index 1908b5b1..a087d28d 100644 --- a/tests/SemanticRelease.Tests.ps1 +++ b/tests/SemanticRelease.Tests.ps1 @@ -7,6 +7,8 @@ Describe 'Semantic release support' { $expectedFunctionList = @( 'Get-ReleaseDateString' 'Get-ReleaseRepositoryUrl' + 'Get-PSResourceRepositoryStoreDirectory' + 'Initialize-PSGalleryRepository' 'ConvertTo-ReleaseTagName' 'Read-JsonFile' 'Write-JsonFile' @@ -120,4 +122,45 @@ All notable changes to this project will be documented in this file. $project = Get-Content -LiteralPath $projectFile -Raw | ConvertFrom-Json $project.Version | Should -Be '2.0.0' } + + It 'Initialize-PSGalleryRepository creates the PSResourceGet store directory and registers PSGallery when missing' { + Mock Get-PSResourceRepositoryStoreDirectory {'/tmp/psresource-store'} + Mock Test-Path {$false} -ParameterFilter {$LiteralPath -eq '/tmp/psresource-store'} + Mock New-Item {} + Mock Get-PSResourceRepository {$null} + Mock Register-PSResourceRepository {} + + Initialize-PSGalleryRepository + + Assert-MockCalled New-Item -Times 1 -ParameterFilter { + $ItemType -eq 'Directory' -and + $Path -eq '/tmp/psresource-store' -and + $Force + } + Assert-MockCalled Get-PSResourceRepository -Times 1 -ParameterFilter {$Name -eq 'PSGallery' -and $ErrorAction -eq 'SilentlyContinue'} + Assert-MockCalled Register-PSResourceRepository -Times 1 -ParameterFilter {$PSGallery} + } + + It 'Initialize-PSGalleryRepository leaves the repository store alone when PSGallery is already registered' { + Mock Get-PSResourceRepositoryStoreDirectory {'/tmp/psresource-store'} + Mock Test-Path {$true} -ParameterFilter {$LiteralPath -eq '/tmp/psresource-store'} + Mock New-Item {} + Mock Get-PSResourceRepository {[pscustomobject]@{Name = 'PSGallery'}} + Mock Register-PSResourceRepository {} + + Initialize-PSGalleryRepository + + Assert-MockCalled New-Item -Times 0 + Assert-MockCalled Register-PSResourceRepository -Times 0 + } + + It 'Publish-ToPSGallery bootstraps semantic-release support before publishing to PSGallery' { + $scriptPath = Join-Path $PSScriptRoot '..' 'scripts' 'release' 'Publish-ToPSGallery.ps1' + $scriptText = Get-Content -LiteralPath $scriptPath -Raw + + $scriptText | Should -Match 'SemanticReleaseSupport\.ps1' + $scriptText | Should -Match 'Initialize-PSGalleryRepository' + $scriptText | Should -Match '\$ErrorActionPreference = ''Stop''' + $scriptText | Should -Match 'Publish-PSResource .* -ErrorAction Stop' + } } From 5b1a059d570364cef20e9dc6cfb8b4fd120312bf Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 08:58:29 +0000 Subject: [PATCH 2/3] chore: prepare next prerelease version [skip ci] --- project.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project.json b/project.json index 669fa611..c47b2507 100644 --- a/project.json +++ b/project.json @@ -1,7 +1,7 @@ { "ProjectName": "NovaModuleTools", "Description": "NovaModuleTools is an enterprise-focused evolution of ModuleTools, designed for large-scale PowerShell projects with a strong emphasis on structure, maintainability, and automated CI/CD pipelines.", - "Version": "2.0.0-beta04", + "Version": "2.0.0-beta05", "Preamble": [ "Set-StrictMode -Version Latest", "$ErrorActionPreference = 'Stop'" From e7b506d519bf86a6794bf3eca3cf65f63c586d32 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 10:51:23 +0000 Subject: [PATCH 3/3] chore: prepare next prerelease version [skip ci] --- project.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project.json b/project.json index c47b2507..0bee0896 100644 --- a/project.json +++ b/project.json @@ -1,7 +1,7 @@ { "ProjectName": "NovaModuleTools", "Description": "NovaModuleTools is an enterprise-focused evolution of ModuleTools, designed for large-scale PowerShell projects with a strong emphasis on structure, maintainability, and automated CI/CD pipelines.", - "Version": "2.0.0-beta05", + "Version": "2.0.0-beta06", "Preamble": [ "Set-StrictMode -Version Latest", "$ErrorActionPreference = 'Stop'"