From 341f92130114c949eb4e5a97f89cab0a7f61ea71 Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Wed, 20 May 2026 18:45:19 -0400 Subject: [PATCH 1/4] ci(release): build release notes from CHANGELOG and harden version expansion Ports the ScheduledTasksManager release-tooling improvements into the template so every module scaffolded from it inherits them. - Create GitHub Release: extract the published version's section from CHANGELOG.md and pass it via --notes-file (in pwsh), instead of --generate-notes. The latter lists every merged PR since the last release tag, which between version bumps is dominated by bot/CI/chore PRs and buries the actual user-facing changes. Includes a Full Changelog compare link and falls back to --generate-notes if a version has no changelog section (so a release is never blocked). - Pass the version output via env (VERSION) in the 'Check if Release Exists', 'Check if PSGallery Version Exists', and 'Create GitHub Release' steps instead of inlining ${{ steps.version.outputs.version }} into the run scripts. Inline template expansion is substituted into the script text before the shell runs (a template-injection vector zizmor/CodeRabbit flag); env vars are read at runtime. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../PublishModuleToPowerShellGallery.yaml | 51 ++++++++++++++++--- 1 file changed, 43 insertions(+), 8 deletions(-) diff --git a/.github/workflows/PublishModuleToPowerShellGallery.yaml b/.github/workflows/PublishModuleToPowerShellGallery.yaml index ef7fd63..d1082b4 100644 --- a/.github/workflows/PublishModuleToPowerShellGallery.yaml +++ b/.github/workflows/PublishModuleToPowerShellGallery.yaml @@ -54,21 +54,24 @@ jobs: shell: bash env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ steps.version.outputs.version }} run: | - if gh release view "v${{ steps.version.outputs.version }}" > /dev/null 2>&1; then + if gh release view "v$VERSION" > /dev/null 2>&1; then echo "exists=true" >> $GITHUB_OUTPUT - echo "GitHub release v${{ steps.version.outputs.version }} already exists" + echo "GitHub release v$VERSION already exists" else echo "exists=false" >> $GITHUB_OUTPUT - echo "GitHub release v${{ steps.version.outputs.version }} does not exist" + echo "GitHub release v$VERSION does not exist" fi - name: Check if PSGallery Version Exists id: check_psgallery if: steps.check_release.outputs.exists == 'false' shell: pwsh + env: + VERSION: ${{ steps.version.outputs.version }} run: | - $version = "${{ steps.version.outputs.version }}" + $version = $env:VERSION $published = Find-Module -Name {{ModuleName}} -RequiredVersion $version -Repository PSGallery -ErrorAction SilentlyContinue if ($published) { Write-Host "PSGallery version $version already exists" @@ -85,13 +88,45 @@ jobs: - name: Create GitHub Release if: steps.check_release.outputs.exists == 'false' - shell: bash + shell: pwsh env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPOSITORY: ${{ github.repository }} + VERSION: ${{ steps.version.outputs.version }} run: | - gh release create "v${{ steps.version.outputs.version }}" \ - --title "v${{ steps.version.outputs.version }}" \ - --generate-notes + $version = $env:VERSION + + # Build release notes from this version's CHANGELOG.md section so the release + # body carries only the curated, user-facing entries (not the full PR list that + # --generate-notes produces, which is dominated by bot/CI/chore PRs). + $headerPattern = '^##\s+\[' + [regex]::Escape($version) + '\]' + $capturing = $false + $captured = [System.Collections.Generic.List[string]]::new() + foreach ($line in (Get-Content -LiteralPath './CHANGELOG.md')) { + if (-not $capturing) { + if ($line -match $headerPattern) { $capturing = $true } + continue + } + if ($line -match '^##\s+\[') { break } # next version header ends the section + $captured.Add($line) + } + $body = ($captured -join "`n").Trim() + + if ([string]::IsNullOrWhiteSpace($body)) { + Write-Host "::warning::No CHANGELOG.md section found for $version; falling back to auto-generated notes." + gh release create "v$version" --title "v$version" --generate-notes + } + else { + # Append a compare link against the most recent existing tag. The v$version + # tag does not exist yet (this step creates it), so the latest tag is the + # previous release. + $previousTag = git tag --list 'v*' --sort=-version:refname | Select-Object -First 1 + if ($previousTag) { + $body += "`n`n**Full Changelog**: https://github.com/$env:REPOSITORY/compare/$previousTag...v$version" + } + Set-Content -LiteralPath './release-notes.md' -Value $body -Encoding utf8 + gh release create "v$version" --title "v$version" --notes-file './release-notes.md' + } - name: Publish to PSGallery if: steps.check_release.outputs.exists == 'false' && steps.check_psgallery.outputs.exists == 'false' From aadfaea3a6b7fccd382c8e24ec82a8233f64415f Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Wed, 20 May 2026 18:45:25 -0400 Subject: [PATCH 2/4] ci(release): populate PSData.ReleaseNotes from CHANGELOG at publish time So a scaffolded module's PowerShell Gallery release-notes panel shows the curated, user-facing notes for each version (matching the GitHub release body) instead of a static CHANGELOG link. - build.depend.psd1: add ChangelogManagement 3.1.0 (Keep a Changelog parser). - build.psake.ps1: new UpdateReleaseNotes task (Depends Build) that reads the entry matching the module version via Get-ChangelogData and sets the built manifest's PrivateData.PSData.ReleaseNotes via Update-ModuleManifest. Wired in via $PSBPublishDependency so it runs before Publish-PSBuildModule. Non-fatal if the changelog can't be read or has no entry for the version. Mirrors the DSC Community Sampler pattern. Scaffolded modules use SemVer Keep a Changelog (CHANGELOG.template.md), the format this was validated against in ScheduledTasksManager. The template repo itself never runs this path (its publish is guarded against the un-initialized placeholder). Co-Authored-By: Claude Opus 4.7 (1M context) --- build.depend.psd1 | 5 +++++ build.psake.ps1 | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/build.depend.psd1 b/build.depend.psd1 index 22be15d..29e4712 100644 --- a/build.depend.psd1 +++ b/build.depend.psd1 @@ -25,4 +25,9 @@ 'PSScriptAnalyzer' = @{ Version = '1.25.0' } + # Parses CHANGELOG.md (Keep a Changelog format) so the Publish task can populate the + # built manifest's PSData.ReleaseNotes from the matching version's entry. + 'ChangelogManagement' = @{ + Version = '3.1.0' + } } diff --git a/build.psake.ps1 b/build.psake.ps1 index cad18bc..531040b 100644 --- a/build.psake.ps1 +++ b/build.psake.ps1 @@ -45,5 +45,46 @@ Task -Name 'Init_Integration' -Description 'Load integration test environment va } } +# Populate the built manifest's ReleaseNotes from the matching CHANGELOG.md entry so the +# PowerShell Gallery release-notes panel shows the curated, user-facing notes (the same +# content used for the GitHub release) instead of just a link. Depends on Build so the +# staged manifest in ModuleOutDir exists; runs before Publish (see $PSBPublishDependency +# below). Non-fatal if the changelog can't be read or has no entry for the version being +# published, so a release is never blocked. +Task -Name 'UpdateReleaseNotes' -Depends 'Build' -Description 'Set built manifest ReleaseNotes from the matching CHANGELOG.md entry' { + $changelogPath = Join-Path -Path $PSScriptRoot -ChildPath 'CHANGELOG.md' + if (-not (Test-Path -Path $changelogPath)) { + Write-Warning 'CHANGELOG.md not found; leaving ReleaseNotes unchanged.' + return + } + + $moduleVersion = $PSBPreference.General.ModuleVersion + try { + Import-Module -Name 'ChangelogManagement' -ErrorAction Stop + $changelogData = Get-ChangelogData -Path $changelogPath -ErrorAction Stop + } + catch { + Write-Warning "Could not read CHANGELOG.md ($($_.Exception.Message)); leaving ReleaseNotes unchanged." + return + } + + $releaseEntry = $changelogData.Released | + Where-Object { [string]$_.Version -eq [string]$moduleVersion } | + Select-Object -First 1 + if (-not $releaseEntry) { + Write-Warning "No CHANGELOG.md entry found for version $moduleVersion; leaving ReleaseNotes unchanged." + return + } + + $releaseNotes = $releaseEntry.RawData.Trim() + $builtManifest = Join-Path -Path $PSBPreference.Build.ModuleOutDir -ChildPath "$($PSBPreference.General.ModuleName).psd1" + Update-ModuleManifest -Path $builtManifest -ReleaseNotes $releaseNotes -ErrorAction Stop + Write-Host " Set ReleaseNotes on built manifest from CHANGELOG [$($releaseEntry.Version)] ($($releaseNotes.Length) chars)" -ForegroundColor Gray +} + +# Inject ReleaseNotes into the built manifest before publishing (PowerShellBuild's Publish +# defaults to depending only on 'Test'). +$PSBPublishDependency = @('Test', 'UpdateReleaseNotes') + # Note: -Depends replaces PowerShellBuild's default dependencies, so we must include Pester and Analyze explicitly Task -Name 'Test' -FromModule 'PowerShellBuild' -MinimumVersion '0.7.3' -Depends 'Init_Integration', 'Pester', 'Analyze' From f4607e88dfcee03fbfc26b77cbf6ec5354f94991 Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Wed, 20 May 2026 19:16:42 -0400 Subject: [PATCH 3/4] ci(release): guard empty changelog notes and unreadable CHANGELOG (review) Addresses Copilot review on #31: - UpdateReleaseNotes: if the matched CHANGELOG entry is empty/whitespace, warn and leave ReleaseNotes unchanged rather than overwriting the built manifest with an empty string. - Create GitHub Release: read CHANGELOG.md defensively (Test-Path + try/catch). A missing or unreadable file now falls back to --generate-notes instead of failing the publish (GitHub's pwsh runs with $ErrorActionPreference = 'Stop'). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../PublishModuleToPowerShellGallery.yaml | 29 ++++++++++++++----- build.psake.ps1 | 4 +++ 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/.github/workflows/PublishModuleToPowerShellGallery.yaml b/.github/workflows/PublishModuleToPowerShellGallery.yaml index d1082b4..336b292 100644 --- a/.github/workflows/PublishModuleToPowerShellGallery.yaml +++ b/.github/workflows/PublishModuleToPowerShellGallery.yaml @@ -99,16 +99,29 @@ jobs: # Build release notes from this version's CHANGELOG.md section so the release # body carries only the curated, user-facing entries (not the full PR list that # --generate-notes produces, which is dominated by bot/CI/chore PRs). - $headerPattern = '^##\s+\[' + [regex]::Escape($version) + '\]' - $capturing = $false + # Read defensively: a missing/unreadable CHANGELOG.md must fall back to + # --generate-notes (below), never fail the publish. + $changelogLines = $null + if (Test-Path -LiteralPath './CHANGELOG.md') { + try { + $changelogLines = Get-Content -LiteralPath './CHANGELOG.md' -ErrorAction Stop + } + catch { + Write-Host "::warning::Could not read CHANGELOG.md ($($_.Exception.Message)); falling back to auto-generated notes." + } + } $captured = [System.Collections.Generic.List[string]]::new() - foreach ($line in (Get-Content -LiteralPath './CHANGELOG.md')) { - if (-not $capturing) { - if ($line -match $headerPattern) { $capturing = $true } - continue + if ($changelogLines) { + $headerPattern = '^##\s+\[' + [regex]::Escape($version) + '\]' + $capturing = $false + foreach ($line in $changelogLines) { + if (-not $capturing) { + if ($line -match $headerPattern) { $capturing = $true } + continue + } + if ($line -match '^##\s+\[') { break } # next version header ends the section + $captured.Add($line) } - if ($line -match '^##\s+\[') { break } # next version header ends the section - $captured.Add($line) } $body = ($captured -join "`n").Trim() diff --git a/build.psake.ps1 b/build.psake.ps1 index 531040b..cc5f0ce 100644 --- a/build.psake.ps1 +++ b/build.psake.ps1 @@ -77,6 +77,10 @@ Task -Name 'UpdateReleaseNotes' -Depends 'Build' -Description 'Set built manifes } $releaseNotes = $releaseEntry.RawData.Trim() + if ([string]::IsNullOrWhiteSpace($releaseNotes)) { + Write-Warning "CHANGELOG.md entry for version $moduleVersion is empty; leaving ReleaseNotes unchanged." + return + } $builtManifest = Join-Path -Path $PSBPreference.Build.ModuleOutDir -ChildPath "$($PSBPreference.General.ModuleName).psd1" Update-ModuleManifest -Path $builtManifest -ReleaseNotes $releaseNotes -ErrorAction Stop Write-Host " Set ReleaseNotes on built manifest from CHANGELOG [$($releaseEntry.Version)] ($($releaseNotes.Length) chars)" -ForegroundColor Gray From 1ba353231e6848f503efc2a9611b04b4a430c025 Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Wed, 20 May 2026 19:20:15 -0400 Subject: [PATCH 4/4] ci(release): exclude current tag from compare-link base (review) Addresses CodeRabbit review on #31: if a v$version tag already exists (e.g. a re-run, or a tag pushed without a release), the previous-tag selection could pick it and produce a self-referential Full Changelog compare link. Filter out "v$version" before selecting the most recent tag. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/PublishModuleToPowerShellGallery.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/PublishModuleToPowerShellGallery.yaml b/.github/workflows/PublishModuleToPowerShellGallery.yaml index 336b292..df7c81f 100644 --- a/.github/workflows/PublishModuleToPowerShellGallery.yaml +++ b/.github/workflows/PublishModuleToPowerShellGallery.yaml @@ -133,7 +133,9 @@ jobs: # Append a compare link against the most recent existing tag. The v$version # tag does not exist yet (this step creates it), so the latest tag is the # previous release. - $previousTag = git tag --list 'v*' --sort=-version:refname | Select-Object -First 1 + $previousTag = git tag --list 'v*' --sort=-version:refname | + Where-Object { $_ -ne "v$version" } | + Select-Object -First 1 if ($previousTag) { $body += "`n`n**Full Changelog**: https://github.com/$env:REPOSITORY/compare/$previousTag...v$version" }