From 7afc5caae720ec3757d1984a041e590153c2b764 Mon Sep 17 00:00:00 2001 From: Sam Erde <20478745+SamErde@users.noreply.github.com> Date: Sat, 9 May 2026 12:16:36 -0400 Subject: [PATCH 1/6] feat: track ModuleVersion and ConfigVersion in maester-config.json Adds two top-level fields to tests/maester-config.json so consumers can identify which Maester release shipped a given config and when the file's content last changed: - ModuleVersion: stamped from the new module version at publish time - ConfigVersion: CalVer YYYY.MM.DD.N derived from git history of the config file (last commit date plus same-day commit count) A new helper script build/Update-MaesterConfigVersion.ps1 performs surgical regex replacement on the JSON to preserve formatting, and auto-computes ConfigVersion from `git log` when not passed explicitly. The four publish workflows (publish-module, -preview, -manualversionupdate, publish-tests) get fetch-depth: 0, a single-line stamp step, and reordered steps so the version is computed before the test-folder copy. Surfacing: Get-MtMaesterConfig logs both fields via Write-Verbose; the HTML report's ConfigPage displays them alongside the existing ConfigSource chip. Pester tests assert both fields are present in the source file and survive load. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../publish-module-manualversionupdate.yaml | 16 ++-- .github/workflows/publish-module-preview.yaml | 16 ++-- .github/workflows/publish-module.yaml | 16 ++-- .github/workflows/publish-tests.yaml | 7 ++ build/Update-MaesterConfigVersion.ps1 | 75 +++++++++++++++++++ powershell/internal/Get-MtMaesterConfig.ps1 | 4 + .../functions/Get-MtMaesterConfig.Tests.ps1 | 5 ++ .../tests/functions/MaesterConfig.Tests.ps1 | 23 ++++++ report/src/pages/ConfigPage.tsx | 15 +++- tests/maester-config.json | 2 + 10 files changed, 162 insertions(+), 17 deletions(-) create mode 100644 build/Update-MaesterConfigVersion.ps1 diff --git a/.github/workflows/publish-module-manualversionupdate.yaml b/.github/workflows/publish-module-manualversionupdate.yaml index 527445f43..f8f0eb9cd 100644 --- a/.github/workflows/publish-module-manualversionupdate.yaml +++ b/.github/workflows/publish-module-manualversionupdate.yaml @@ -32,6 +32,8 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 - name: Download updated report template if: needs.build-report.outputs.report-updated == 'true' @@ -40,16 +42,20 @@ jobs: name: updated-report-template path: powershell/assets/ - - name: Package ./tests to PowerShell module - id: package-tests - shell: pwsh - run: ./build/Copy-MaesterTestsToPSModule.ps1 -Force - - name: Get current module version id: moduleversion shell: pwsh run: ./.github/workflows/get-version.ps1 + - name: Stamp maester-config.json version fields + shell: pwsh + run: ./build/Update-MaesterConfigVersion.ps1 -ConfigPath tests/maester-config.json -ModuleVersion '${{ steps.moduleversion.outputs.tag }}' + + - name: Package ./tests to PowerShell module + id: package-tests + shell: pwsh + run: ./build/Copy-MaesterTestsToPSModule.ps1 -Force + - name: Update PowerShell Module to PowerShell Gallery id: publish-to-gallery shell: pwsh diff --git a/.github/workflows/publish-module-preview.yaml b/.github/workflows/publish-module-preview.yaml index 8c662d15f..b529ab2f8 100644 --- a/.github/workflows/publish-module-preview.yaml +++ b/.github/workflows/publish-module-preview.yaml @@ -32,6 +32,8 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 - name: Download updated report template if: needs.build-report.outputs.report-updated == 'true' @@ -40,16 +42,20 @@ jobs: name: updated-report-template path: powershell/assets/ - - name: Package ./tests to PowerShell module - id: package-tests - shell: pwsh - run: ./build/Copy-MaesterTestsToPSModule.ps1 -Force - - name: Set module version id: moduleversion shell: pwsh run: ./.github/workflows/minor-version-update.ps1 -preview + - name: Stamp maester-config.json version fields + shell: pwsh + run: ./build/Update-MaesterConfigVersion.ps1 -ConfigPath tests/maester-config.json -ModuleVersion '${{ steps.moduleversion.outputs.tag }}' + + - name: Package ./tests to PowerShell module + id: package-tests + shell: pwsh + run: ./build/Copy-MaesterTestsToPSModule.ps1 -Force + - name: Archive PowerShell build uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: diff --git a/.github/workflows/publish-module.yaml b/.github/workflows/publish-module.yaml index 418e62f3d..e1739e72f 100644 --- a/.github/workflows/publish-module.yaml +++ b/.github/workflows/publish-module.yaml @@ -32,6 +32,8 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 - name: Download updated report template if: needs.build-report.outputs.report-updated == 'true' @@ -40,16 +42,20 @@ jobs: name: updated-report-template path: powershell/assets/ - - name: Package ./tests to PowerShell module - id: package-tests - shell: pwsh - run: ./build/Copy-MaesterTestsToPSModule.ps1 -Force - - name: Set module version id: moduleversion shell: pwsh run: ./.github/workflows/minor-version-update.ps1 + - name: Stamp maester-config.json version fields + shell: pwsh + run: ./build/Update-MaesterConfigVersion.ps1 -ConfigPath tests/maester-config.json -ModuleVersion '${{ steps.moduleversion.outputs.tag }}' + + - name: Package ./tests to PowerShell module + id: package-tests + shell: pwsh + run: ./build/Copy-MaesterTestsToPSModule.ps1 -Force + - name: Update PowerShell Module to PowerShell Gallery id: publish-to-gallery shell: pwsh diff --git a/.github/workflows/publish-tests.yaml b/.github/workflows/publish-tests.yaml index aadd14e5d..a5c5187f5 100644 --- a/.github/workflows/publish-tests.yaml +++ b/.github/workflows/publish-tests.yaml @@ -21,6 +21,13 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + + - name: Stamp maester-config.json version fields + shell: pwsh + run: ./build/Update-MaesterConfigVersion.ps1 -ConfigPath ./tests/maester-config.json -ModuleVersion '${{ github.event.release.tag_name }}' + - name: Publish to maester-tests id: push_directory uses: cpina/github-action-push-to-another-repository@3fc9348237c8c6954ff88e58719af8a88af543f7 diff --git a/build/Update-MaesterConfigVersion.ps1 b/build/Update-MaesterConfigVersion.ps1 new file mode 100644 index 000000000..345531d67 --- /dev/null +++ b/build/Update-MaesterConfigVersion.ps1 @@ -0,0 +1,75 @@ +<# +.SYNOPSIS + Stamps the ModuleVersion and ConfigVersion fields at the top of a + maester-config.json file using surgical regex replacement. + +.DESCRIPTION + Preserves the file's existing 2-space indentation and overall layout by + avoiding a JSON round-trip. Validates the input and output are valid JSON. + Writes UTF-8 without BOM. + + Inserts either field if absent so the script also works on a config file + that has not yet been migrated to include these fields. + + ConfigVersion is a CalVer-style YYYY.MM.DD.N string derived from git + history of the config file: YYYY.MM.DD is the date of the most recent + commit to the file; N is the count of commits to the file on that date. + Auto-computed when -ConfigVersion is omitted (the normal CI path). + + Requires sufficient git history to find the last commit touching the file, + so callers should run actions/checkout with fetch-depth: 0. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] [string] $ConfigPath, + [Parameter(Mandatory)] [string] $ModuleVersion, + [Parameter()] [string] $ConfigVersion +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +if (-not (Test-Path -LiteralPath $ConfigPath)) { + throw "Config file not found: $ConfigPath" +} + +if ([string]::IsNullOrWhiteSpace($ConfigVersion)) { + $dates = @(git log --format=%cd --date=format:%Y.%m.%d -- $ConfigPath) + if ($LASTEXITCODE -ne 0 -or $dates.Count -eq 0) { + throw "Could not determine ConfigVersion: no git history found for $ConfigPath" + } + $lastDate = $dates[0] + $sameDayCount = @($dates | Where-Object { $_ -eq $lastDate }).Count + $ConfigVersion = "$lastDate.$sameDayCount" + Write-Verbose "Computed ConfigVersion=$ConfigVersion (date $lastDate, $sameDayCount commit(s) that day)" +} + +$content = Get-Content -LiteralPath $ConfigPath -Raw +try { $null = $content | ConvertFrom-Json } catch { throw "Input file is not valid JSON: $_" } + +$mvLine = '"ModuleVersion": "{0}"' -f $ModuleVersion +$cvLine = '"ConfigVersion": "{0}"' -f $ConfigVersion + +$mvRegex = [regex]'"ModuleVersion"\s*:\s*"[^"]*"' +if ($mvRegex.IsMatch($content)) { + $content = $mvRegex.Replace($content, $mvLine, 1) +} else { + # Insert as first key after the opening brace. + $content = $content -replace '^\{\s*', "{`n $mvLine,`n " +} + +$cvRegex = [regex]'"ConfigVersion"\s*:\s*"[^"]*"' +if ($cvRegex.IsMatch($content)) { + $content = $cvRegex.Replace($content, $cvLine, 1) +} else { + # Insert immediately after the ModuleVersion line. + $anchorRegex = [regex]([regex]::Escape($mvLine) + ',') + $content = $anchorRegex.Replace($content, "$mvLine,`n $cvLine,", 1) +} + +try { $null = $content | ConvertFrom-Json } catch { throw "Output is not valid JSON after stamping: $_" } + +$utf8NoBom = [System.Text.UTF8Encoding]::new($false) +[System.IO.File]::WriteAllText((Resolve-Path -LiteralPath $ConfigPath).Path, $content, $utf8NoBom) + +Write-Host "Stamped ${ConfigPath}: ModuleVersion=$ModuleVersion, ConfigVersion=$ConfigVersion" diff --git a/powershell/internal/Get-MtMaesterConfig.ps1 b/powershell/internal/Get-MtMaesterConfig.ps1 index 7270578a6..a1a19509b 100644 --- a/powershell/internal/Get-MtMaesterConfig.ps1 +++ b/powershell/internal/Get-MtMaesterConfig.ps1 @@ -117,6 +117,10 @@ function Get-MtMaesterConfig { Write-Verbose "Loading Maester config from: $ConfigFilePath" $maesterConfig = Get-Content -Path $ConfigFilePath -Raw | ConvertFrom-Json + $loadedModuleVersion = if ($maesterConfig.PSObject.Properties.Name -contains 'ModuleVersion') { $maesterConfig.ModuleVersion } else { '' } + $loadedConfigVersion = if ($maesterConfig.PSObject.Properties.Name -contains 'ConfigVersion') { $maesterConfig.ConfigVersion } else { '' } + Write-Verbose "Loaded Maester config: ModuleVersion=$loadedModuleVersion, ConfigVersion=$loadedConfigVersion" + # Store the source file name so the report can show which config was loaded $configFileName = Split-Path -Path $ConfigFilePath -Leaf Add-Member -InputObject $maesterConfig -MemberType NoteProperty -Name 'ConfigSource' -Value $configFileName diff --git a/powershell/tests/functions/Get-MtMaesterConfig.Tests.ps1 b/powershell/tests/functions/Get-MtMaesterConfig.Tests.ps1 index c769d81ad..912b1c98c 100644 --- a/powershell/tests/functions/Get-MtMaesterConfig.Tests.ps1 +++ b/powershell/tests/functions/Get-MtMaesterConfig.Tests.ps1 @@ -26,6 +26,11 @@ #$sample.Title | Should -Not -Be 'Overridden Title from Custom Config' $result.ConfigSource | Should -Be 'maester-config.json' + + # Version fields survive load + $result.PSObject.Properties.Name | Should -Contain 'ModuleVersion' + $result.ModuleVersion | Should -Not -BeNullOrEmpty + $result.PSObject.Properties.Name | Should -Contain 'ConfigVersion' } Context 'Tenant-specific config' { diff --git a/powershell/tests/functions/MaesterConfig.Tests.ps1 b/powershell/tests/functions/MaesterConfig.Tests.ps1 index 922874a95..ce568b794 100644 --- a/powershell/tests/functions/MaesterConfig.Tests.ps1 +++ b/powershell/tests/functions/MaesterConfig.Tests.ps1 @@ -1,4 +1,27 @@ Describe 'Maester Configuration File - tests/maester-config.json' { + Context 'Version fields' { + It 'has a top-level ModuleVersion string' { + $repoRoot = Resolve-Path (Join-Path $PSScriptRoot '../../..') + $configPath = Join-Path $repoRoot 'tests/maester-config.json' + $configJson = Get-Content -Path $configPath -Raw | ConvertFrom-Json + + $configJson.PSObject.Properties.Name | Should -Contain 'ModuleVersion' + $configJson.ModuleVersion | Should -BeOfType [string] + $configJson.ModuleVersion | Should -Not -BeNullOrEmpty + } + + It 'has a top-level ConfigVersion string (CalVer YYYY.MM.DD.N or empty sentinel)' { + $repoRoot = Resolve-Path (Join-Path $PSScriptRoot '../../..') + $configPath = Join-Path $repoRoot 'tests/maester-config.json' + $configJson = Get-Content -Path $configPath -Raw | ConvertFrom-Json + + $configJson.PSObject.Properties.Name | Should -Contain 'ConfigVersion' + $configJson.ConfigVersion | Should -BeOfType [string] + # Empty (source sentinel) or CalVer format. CI stamps the CalVer at publish time. + $configJson.ConfigVersion | Should -Match '^$|^\d{4}\.\d{2}\.\d{2}\.\d+$' + } + } + Context 'TestSettings array' { It 'should be sorted by Id' { # Correctly join paths to find the repo root and config file diff --git a/report/src/pages/ConfigPage.tsx b/report/src/pages/ConfigPage.tsx index 32bdbf78e..ad56f1819 100644 --- a/report/src/pages/ConfigPage.tsx +++ b/report/src/pages/ConfigPage.tsx @@ -289,6 +289,11 @@ export default function ConfigPage() { } const configSource = originalConfig?.ConfigSource + const moduleVersion = originalConfig?.ModuleVersion + const configVersion = originalConfig?.ConfigVersion + const showSource = isMultiTenant && !!configSource + const showInfoBar = showSource || !!moduleVersion || !!configVersion + const codeChip = "px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-800 font-mono text-xs" return (
@@ -296,10 +301,16 @@ export default function ConfigPage() {

Maester Configuration

- {isMultiTenant && configSource && ( + {showInfoBar && (
- Loaded from: {configSource} + + {showSource && <>Loaded from: {configSource}} + {showSource && (moduleVersion || configVersion) && <> · } + {moduleVersion && <>module {moduleVersion}} + {moduleVersion && configVersion && <> · } + {configVersion && <>config {configVersion}} +
)} diff --git a/tests/maester-config.json b/tests/maester-config.json index e055960b7..436da1d6e 100644 --- a/tests/maester-config.json +++ b/tests/maester-config.json @@ -1,4 +1,6 @@ { + "ModuleVersion": "2.0.0", + "ConfigVersion": "", "GlobalSettings": { "EmergencyAccessAccounts": [], "DataverseEnvironmentUrl": "" From 26e35da35ffaff68aaf71d32b4780cbd4d94a214 Mon Sep 17 00:00:00 2001 From: Sam Erde <20478745+SamErde@users.noreply.github.com> Date: Sat, 9 May 2026 12:50:21 -0400 Subject: [PATCH 2/6] fix: address Copilot review on PR #1760 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update-MaesterConfigVersion.ps1: remove the absent-field insertion paths. The anchor for inserting ConfigVersion depended on ModuleVersion having a trailing comma, which fails if ModuleVersion is the last property. Replaced with a clear error that asks the caller to add missing fields manually — schema changes are now explicit in source control rather than handled by fragile defensive code. - publish-tests.yaml: on workflow_dispatch, github.event.release.tag_name is empty and would have stamped ModuleVersion as "". Added a Resolve latest release tag step that prefers the event tag (release trigger) and falls back to the latest published release via gh release view (workflow_dispatch). Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/publish-tests.yaml | 17 ++++++++++++++++- build/Update-MaesterConfigVersion.ps1 | 23 ++++++++++------------- 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/.github/workflows/publish-tests.yaml b/.github/workflows/publish-tests.yaml index a5c5187f5..d47446749 100644 --- a/.github/workflows/publish-tests.yaml +++ b/.github/workflows/publish-tests.yaml @@ -24,9 +24,24 @@ jobs: with: fetch-depth: 0 + - name: Resolve latest release tag + id: release_tag + shell: pwsh + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + $tag = '${{ github.event.release.tag_name }}' + if ([string]::IsNullOrWhiteSpace($tag)) { + $tag = (gh release view --repo ${{ github.repository }} --json tagName -q .tagName) + } + if ([string]::IsNullOrWhiteSpace($tag)) { + throw "Could not resolve a release tag for the stamp step." + } + "tag=$tag" | Out-File -Append -FilePath $env:GITHUB_OUTPUT + - name: Stamp maester-config.json version fields shell: pwsh - run: ./build/Update-MaesterConfigVersion.ps1 -ConfigPath ./tests/maester-config.json -ModuleVersion '${{ github.event.release.tag_name }}' + run: ./build/Update-MaesterConfigVersion.ps1 -ConfigPath ./tests/maester-config.json -ModuleVersion '${{ steps.release_tag.outputs.tag }}' - name: Publish to maester-tests id: push_directory diff --git a/build/Update-MaesterConfigVersion.ps1 b/build/Update-MaesterConfigVersion.ps1 index 345531d67..f5706e4cb 100644 --- a/build/Update-MaesterConfigVersion.ps1 +++ b/build/Update-MaesterConfigVersion.ps1 @@ -8,8 +8,10 @@ avoiding a JSON round-trip. Validates the input and output are valid JSON. Writes UTF-8 without BOM. - Inserts either field if absent so the script also works on a config file - that has not yet been migrated to include these fields. + Both fields must already exist in the source file. The script does not + insert missing fields — if either is absent, it throws and asks the + caller to add them manually. This avoids fragile insertion logic and + makes schema changes explicit in source control. ConfigVersion is a CalVer-style YYYY.MM.DD.N string derived from git history of the config file: YYYY.MM.DD is the date of the most recent @@ -51,21 +53,16 @@ $mvLine = '"ModuleVersion": "{0}"' -f $ModuleVersion $cvLine = '"ConfigVersion": "{0}"' -f $ConfigVersion $mvRegex = [regex]'"ModuleVersion"\s*:\s*"[^"]*"' -if ($mvRegex.IsMatch($content)) { - $content = $mvRegex.Replace($content, $mvLine, 1) -} else { - # Insert as first key after the opening brace. - $content = $content -replace '^\{\s*', "{`n $mvLine,`n " +if (-not $mvRegex.IsMatch($content)) { + throw "Required field ModuleVersion not found at the top level of $ConfigPath. Add `"ModuleVersion`": `"`" before re-running." } +$content = $mvRegex.Replace($content, $mvLine, 1) $cvRegex = [regex]'"ConfigVersion"\s*:\s*"[^"]*"' -if ($cvRegex.IsMatch($content)) { - $content = $cvRegex.Replace($content, $cvLine, 1) -} else { - # Insert immediately after the ModuleVersion line. - $anchorRegex = [regex]([regex]::Escape($mvLine) + ',') - $content = $anchorRegex.Replace($content, "$mvLine,`n $cvLine,", 1) +if (-not $cvRegex.IsMatch($content)) { + throw "Required field ConfigVersion not found at the top level of $ConfigPath. Add `"ConfigVersion`": `"`" before re-running." } +$content = $cvRegex.Replace($content, $cvLine, 1) try { $null = $content | ConvertFrom-Json } catch { throw "Output is not valid JSON after stamping: $_" } From 80dd357c933519afad188920eea9e6d44e2f3f83 Mon Sep 17 00:00:00 2001 From: Sam Erde <20478745+SamErde@users.noreply.github.com> Date: Sat, 9 May 2026 13:17:49 -0400 Subject: [PATCH 3/6] fix: address Copilot review round 2 on PR #1760 - Auto-compute path resolution: resolve to a repo-relative path via `git rev-parse --show-toplevel` and run `git -C log -- `, so the script works regardless of CWD or whether ConfigPath was passed as relative or absolute. Previously a non-repo-root CWD or absolute path could leave git unable to find the file's history. - Top-level regex: switch ModuleVersion/ConfigVersion regexes to multiline mode with a leading-whitespace capture group. The match now requires the field to start a line (i.e. be a top-level key), not appear anywhere in the JSON. Indent is preserved via the capture-group backreference. Error messages updated to describe the actual contract. Co-Authored-By: Claude Opus 4.7 (1M context) --- build/Update-MaesterConfigVersion.ps1 | 29 +++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/build/Update-MaesterConfigVersion.ps1 b/build/Update-MaesterConfigVersion.ps1 index f5706e4cb..d71da3e28 100644 --- a/build/Update-MaesterConfigVersion.ps1 +++ b/build/Update-MaesterConfigVersion.ps1 @@ -36,9 +36,19 @@ if (-not (Test-Path -LiteralPath $ConfigPath)) { } if ([string]::IsNullOrWhiteSpace($ConfigVersion)) { - $dates = @(git log --format=%cd --date=format:%Y.%m.%d -- $ConfigPath) + # Resolve to a repo-relative path so git lookup works regardless of CWD + # or whether ConfigPath was passed as relative or absolute. + $resolvedConfigPath = (Resolve-Path -LiteralPath $ConfigPath).Path + $configDir = Split-Path -Parent $resolvedConfigPath + $repoRoot = (& git -C $configDir rev-parse --show-toplevel 2>$null) + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($repoRoot)) { + throw "Could not determine ConfigVersion: $ConfigPath is not inside a git repository." + } + $repoRoot = $repoRoot.Trim() + $repoRelative = [System.IO.Path]::GetRelativePath($repoRoot, $resolvedConfigPath).Replace('\', '/') + $dates = @(& git -C $repoRoot log --format=%cd --date=format:%Y.%m.%d -- $repoRelative) if ($LASTEXITCODE -ne 0 -or $dates.Count -eq 0) { - throw "Could not determine ConfigVersion: no git history found for $ConfigPath" + throw "Could not determine ConfigVersion: no git history found for $repoRelative in $repoRoot." } $lastDate = $dates[0] $sameDayCount = @($dates | Where-Object { $_ -eq $lastDate }).Count @@ -52,17 +62,20 @@ try { $null = $content | ConvertFrom-Json } catch { throw "Input file is not val $mvLine = '"ModuleVersion": "{0}"' -f $ModuleVersion $cvLine = '"ConfigVersion": "{0}"' -f $ConfigVersion -$mvRegex = [regex]'"ModuleVersion"\s*:\s*"[^"]*"' +# Multiline mode + leading-whitespace capture preserves the file's indentation +# and only matches keys that start a line (avoiding accidental matches on a +# nested property that happens to share the name). +$mvRegex = [regex]'(?m)^([ \t]*)"ModuleVersion"\s*:\s*"[^"]*"' if (-not $mvRegex.IsMatch($content)) { - throw "Required field ModuleVersion not found at the top level of $ConfigPath. Add `"ModuleVersion`": `"`" before re-running." + throw "Required field ModuleVersion not found in $ConfigPath. Add `"ModuleVersion`": `"`" as a top-level key before re-running." } -$content = $mvRegex.Replace($content, $mvLine, 1) +$content = $mvRegex.Replace($content, ('$1' + $mvLine), 1) -$cvRegex = [regex]'"ConfigVersion"\s*:\s*"[^"]*"' +$cvRegex = [regex]'(?m)^([ \t]*)"ConfigVersion"\s*:\s*"[^"]*"' if (-not $cvRegex.IsMatch($content)) { - throw "Required field ConfigVersion not found at the top level of $ConfigPath. Add `"ConfigVersion`": `"`" before re-running." + throw "Required field ConfigVersion not found in $ConfigPath. Add `"ConfigVersion`": `"`" as a top-level key before re-running." } -$content = $cvRegex.Replace($content, $cvLine, 1) +$content = $cvRegex.Replace($content, ('$1' + $cvLine), 1) try { $null = $content | ConvertFrom-Json } catch { throw "Output is not valid JSON after stamping: $_" } From 4c62a6152f941166b636ea9583eaeaebe20d2aa5 Mon Sep 17 00:00:00 2001 From: Sam Erde <20478745+SamErde@users.noreply.github.com> Date: Sat, 9 May 2026 13:30:42 -0400 Subject: [PATCH 4/6] fix: address Copilot review round 3 on PR #1760 - Update-MaesterConfigVersion.ps1: tighten the top-level regex from `^[ \t]*` (any leading whitespace) to `^ ` (exactly 2 spaces). The config file uses 2-space indent for top-level keys and 4+ for nested ones, so this rules out accidentally matching a nested object that happens to share a property name. Error messages updated to describe the indent contract. - MaesterConfig.Tests.ps1: add a parity assertion that the source tests/maester-config.json `ModuleVersion` equals powershell/Maester.psd1 `ModuleVersion`. The published artifact gets the real version stamped by CI, but maintainers should keep the source values in sync so a clone shows a sensible number; this test guards against drift. Co-Authored-By: Claude Opus 4.7 (1M context) --- build/Update-MaesterConfigVersion.ps1 | 18 +++++++++--------- .../tests/functions/MaesterConfig.Tests.ps1 | 14 ++++++++++++++ 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/build/Update-MaesterConfigVersion.ps1 b/build/Update-MaesterConfigVersion.ps1 index d71da3e28..f8eb1955a 100644 --- a/build/Update-MaesterConfigVersion.ps1 +++ b/build/Update-MaesterConfigVersion.ps1 @@ -62,20 +62,20 @@ try { $null = $content | ConvertFrom-Json } catch { throw "Input file is not val $mvLine = '"ModuleVersion": "{0}"' -f $ModuleVersion $cvLine = '"ConfigVersion": "{0}"' -f $ConfigVersion -# Multiline mode + leading-whitespace capture preserves the file's indentation -# and only matches keys that start a line (avoiding accidental matches on a -# nested property that happens to share the name). -$mvRegex = [regex]'(?m)^([ \t]*)"ModuleVersion"\s*:\s*"[^"]*"' +# Multiline mode + exact 2-space indent prefix matches only top-level keys in +# this file's formatting (top level uses 2 spaces, nested keys use 4+). This +# rules out accidental matches on a nested property of the same name. +$mvRegex = [regex]'(?m)^ "ModuleVersion"\s*:\s*"[^"]*"' if (-not $mvRegex.IsMatch($content)) { - throw "Required field ModuleVersion not found in $ConfigPath. Add `"ModuleVersion`": `"`" as a top-level key before re-running." + throw "Required field ModuleVersion not found at the top level of $ConfigPath (must be at 2-space indent). Add `"ModuleVersion`": `"`" as a top-level key before re-running." } -$content = $mvRegex.Replace($content, ('$1' + $mvLine), 1) +$content = $mvRegex.Replace($content, (' ' + $mvLine), 1) -$cvRegex = [regex]'(?m)^([ \t]*)"ConfigVersion"\s*:\s*"[^"]*"' +$cvRegex = [regex]'(?m)^ "ConfigVersion"\s*:\s*"[^"]*"' if (-not $cvRegex.IsMatch($content)) { - throw "Required field ConfigVersion not found in $ConfigPath. Add `"ConfigVersion`": `"`" as a top-level key before re-running." + throw "Required field ConfigVersion not found at the top level of $ConfigPath (must be at 2-space indent). Add `"ConfigVersion`": `"`" as a top-level key before re-running." } -$content = $cvRegex.Replace($content, ('$1' + $cvLine), 1) +$content = $cvRegex.Replace($content, (' ' + $cvLine), 1) try { $null = $content | ConvertFrom-Json } catch { throw "Output is not valid JSON after stamping: $_" } diff --git a/powershell/tests/functions/MaesterConfig.Tests.ps1 b/powershell/tests/functions/MaesterConfig.Tests.ps1 index ce568b794..215fefbee 100644 --- a/powershell/tests/functions/MaesterConfig.Tests.ps1 +++ b/powershell/tests/functions/MaesterConfig.Tests.ps1 @@ -10,6 +10,20 @@ $configJson.ModuleVersion | Should -Not -BeNullOrEmpty } + It 'source ModuleVersion matches powershell/Maester.psd1 ModuleVersion' { + # The published artifact's ModuleVersion is stamped by CI, but the + # source-tree value should track Maester.psd1 so a clone shows a + # sensible number. Drift between the two is a maintenance bug. + $repoRoot = Resolve-Path (Join-Path $PSScriptRoot '../../..') + $configPath = Join-Path $repoRoot 'tests/maester-config.json' + $manifestPath = Join-Path $repoRoot 'powershell/Maester.psd1' + + $configJson = Get-Content -Path $configPath -Raw | ConvertFrom-Json + $manifest = Import-PowerShellDataFile -Path $manifestPath + + $configJson.ModuleVersion | Should -Be $manifest.ModuleVersion -Because "tests/maester-config.json ModuleVersion ($($configJson.ModuleVersion)) should match powershell/Maester.psd1 ModuleVersion ($($manifest.ModuleVersion))" + } + It 'has a top-level ConfigVersion string (CalVer YYYY.MM.DD.N or empty sentinel)' { $repoRoot = Resolve-Path (Join-Path $PSScriptRoot '../../..') $configPath = Join-Path $repoRoot 'tests/maester-config.json' From 2c91e325becd9f7f6665ec5b39a6b87fc15241a5 Mon Sep 17 00:00:00 2001 From: Sam Erde <20478745+SamErde@users.noreply.github.com> Date: Sat, 9 May 2026 13:36:17 -0400 Subject: [PATCH 5/6] fix: quote github.repository expansion in gh release view call Use $env:GITHUB_REPOSITORY instead of inline ${{ github.repository }} for the --repo argument in publish-tests.yaml. Belt-and-suspenders defensive practice flagged by Copilot review; reading the value from the env var avoids any chance of pwsh parser interpretation of the owner/repo token and is the more conventional GHA pattern anyway. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/publish-tests.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish-tests.yaml b/.github/workflows/publish-tests.yaml index d47446749..c6bbc823a 100644 --- a/.github/workflows/publish-tests.yaml +++ b/.github/workflows/publish-tests.yaml @@ -32,7 +32,7 @@ jobs: run: | $tag = '${{ github.event.release.tag_name }}' if ([string]::IsNullOrWhiteSpace($tag)) { - $tag = (gh release view --repo ${{ github.repository }} --json tagName -q .tagName) + $tag = (gh release view --repo $env:GITHUB_REPOSITORY --json tagName -q .tagName) } if ([string]::IsNullOrWhiteSpace($tag)) { throw "Could not resolve a release tag for the stamp step." From 607f169e19959b374933430f1aa7d561f272ad73 Mon Sep 17 00:00:00 2001 From: Sam Erde <20478745+SamErde@users.noreply.github.com> Date: Tue, 12 May 2026 14:45:10 -0400 Subject: [PATCH 6/6] fix: address config version review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- build/Update-MaesterConfigVersion.ps1 | 56 +++---- .../Update-MaesterConfigVersion.Tests.ps1 | 148 ++++++++++++++++++ 2 files changed, 177 insertions(+), 27 deletions(-) create mode 100644 powershell/tests/functions/Update-MaesterConfigVersion.Tests.ps1 diff --git a/build/Update-MaesterConfigVersion.ps1 b/build/Update-MaesterConfigVersion.ps1 index f8eb1955a..8a7fa6f09 100644 --- a/build/Update-MaesterConfigVersion.ps1 +++ b/build/Update-MaesterConfigVersion.ps1 @@ -1,12 +1,12 @@ <# .SYNOPSIS Stamps the ModuleVersion and ConfigVersion fields at the top of a - maester-config.json file using surgical regex replacement. + maester-config.json file. .DESCRIPTION - Preserves the file's existing 2-space indentation and overall layout by - avoiding a JSON round-trip. Validates the input and output are valid JSON. - Writes UTF-8 without BOM. + Reads and updates the JSON object directly, then writes UTF-8 without BOM. + Uses an explicit JSON depth when writing so future nested settings are not + truncated by the default ConvertTo-Json depth. Both fields must already exist in the source file. The script does not insert missing fields — if either is absent, it throws and asks the @@ -35,10 +35,10 @@ if (-not (Test-Path -LiteralPath $ConfigPath)) { throw "Config file not found: $ConfigPath" } -if ([string]::IsNullOrWhiteSpace($ConfigVersion)) { +if (-not $PSBoundParameters.ContainsKey('ConfigVersion')) { # Resolve to a repo-relative path so git lookup works regardless of CWD # or whether ConfigPath was passed as relative or absolute. - $resolvedConfigPath = (Resolve-Path -LiteralPath $ConfigPath).Path + $resolvedConfigPath = (Resolve-Path -LiteralPath $ConfigPath).ProviderPath $configDir = Split-Path -Parent $resolvedConfigPath $repoRoot = (& git -C $configDir rev-parse --show-toplevel 2>$null) if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($repoRoot)) { @@ -46,40 +46,42 @@ if ([string]::IsNullOrWhiteSpace($ConfigVersion)) { } $repoRoot = $repoRoot.Trim() $repoRelative = [System.IO.Path]::GetRelativePath($repoRoot, $resolvedConfigPath).Replace('\', '/') - $dates = @(& git -C $repoRoot log --format=%cd --date=format:%Y.%m.%d -- $repoRelative) - if ($LASTEXITCODE -ne 0 -or $dates.Count -eq 0) { + $commitTimestamps = @(& git -C $repoRoot log --format=%ct -- $repoRelative) + if ($LASTEXITCODE -ne 0 -or $commitTimestamps.Count -eq 0) { throw "Could not determine ConfigVersion: no git history found for $repoRelative in $repoRoot." } - $lastDate = $dates[0] - $sameDayCount = @($dates | Where-Object { $_ -eq $lastDate }).Count + $utcDates = @($commitTimestamps | ForEach-Object { + [System.DateTimeOffset]::FromUnixTimeSeconds([long]$_).UtcDateTime.ToString('yyyy.MM.dd', [System.Globalization.CultureInfo]::InvariantCulture) + }) + $lastDate = $utcDates[0] + $sameDayCount = @($utcDates | Where-Object { $_ -eq $lastDate }).Count $ConfigVersion = "$lastDate.$sameDayCount" Write-Verbose "Computed ConfigVersion=$ConfigVersion (date $lastDate, $sameDayCount commit(s) that day)" } $content = Get-Content -LiteralPath $ConfigPath -Raw -try { $null = $content | ConvertFrom-Json } catch { throw "Input file is not valid JSON: $_" } - -$mvLine = '"ModuleVersion": "{0}"' -f $ModuleVersion -$cvLine = '"ConfigVersion": "{0}"' -f $ConfigVersion +try { + $config = $content | ConvertFrom-Json +} catch { + throw "Input file is not valid JSON: $_" +} -# Multiline mode + exact 2-space indent prefix matches only top-level keys in -# this file's formatting (top level uses 2 spaces, nested keys use 4+). This -# rules out accidental matches on a nested property of the same name. -$mvRegex = [regex]'(?m)^ "ModuleVersion"\s*:\s*"[^"]*"' -if (-not $mvRegex.IsMatch($content)) { - throw "Required field ModuleVersion not found at the top level of $ConfigPath (must be at 2-space indent). Add `"ModuleVersion`": `"`" as a top-level key before re-running." +if (-not ($config.PSObject.Properties.Name -contains 'ModuleVersion')) { + throw "Required field ModuleVersion not found in $ConfigPath. Add `"ModuleVersion`": `"`" as a top-level key before re-running." } -$content = $mvRegex.Replace($content, (' ' + $mvLine), 1) -$cvRegex = [regex]'(?m)^ "ConfigVersion"\s*:\s*"[^"]*"' -if (-not $cvRegex.IsMatch($content)) { - throw "Required field ConfigVersion not found at the top level of $ConfigPath (must be at 2-space indent). Add `"ConfigVersion`": `"`" as a top-level key before re-running." +if (-not ($config.PSObject.Properties.Name -contains 'ConfigVersion')) { + throw "Required field ConfigVersion not found in $ConfigPath. Add `"ConfigVersion`": `"`" as a top-level key before re-running." } -$content = $cvRegex.Replace($content, (' ' + $cvLine), 1) -try { $null = $content | ConvertFrom-Json } catch { throw "Output is not valid JSON after stamping: $_" } +$config.ModuleVersion = $ModuleVersion +$config.ConfigVersion = $ConfigVersion +$updatedContent = $config | ConvertTo-Json -Depth 10 -WarningAction Stop + +try { $null = $updatedContent | ConvertFrom-Json } catch { throw "Output is not valid JSON after stamping: $_" } $utf8NoBom = [System.Text.UTF8Encoding]::new($false) -[System.IO.File]::WriteAllText((Resolve-Path -LiteralPath $ConfigPath).Path, $content, $utf8NoBom) +$resolvedOutputPath = (Resolve-Path -LiteralPath $ConfigPath).ProviderPath +[System.IO.File]::WriteAllText($resolvedOutputPath, $updatedContent, $utf8NoBom) Write-Host "Stamped ${ConfigPath}: ModuleVersion=$ModuleVersion, ConfigVersion=$ConfigVersion" diff --git a/powershell/tests/functions/Update-MaesterConfigVersion.Tests.ps1 b/powershell/tests/functions/Update-MaesterConfigVersion.Tests.ps1 new file mode 100644 index 000000000..6ce16e746 --- /dev/null +++ b/powershell/tests/functions/Update-MaesterConfigVersion.Tests.ps1 @@ -0,0 +1,148 @@ +BeforeAll { + $script:BuildScriptPath = Resolve-Path "$PSScriptRoot/../../../build/Update-MaesterConfigVersion.ps1" + + function Write-TestConfig { + param ( + [Parameter(Mandatory)] + [string] $Path, + + [Parameter(Mandatory)] + [string] $Content + ) + + $utf8NoBom = [System.Text.UTF8Encoding]::new($false) + $resolvedPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) + [System.IO.File]::WriteAllText($resolvedPath, $Content, $utf8NoBom) + } +} + +Describe 'Update-MaesterConfigVersion' { + It 'updates existing ModuleVersion and ConfigVersion fields' { + $configPath = Join-Path 'TestDrive:' 'maester-config.json' + Write-TestConfig -Path $configPath -Content @' +{ + "ModuleVersion": "1.0.0", + "ConfigVersion": "", + "GlobalSettings": { + "EmergencyAccessAccounts": [] + }, + "TestSettings": [ + { + "Id": "MT.1001", + "Severity": "High" + } + ] +} +'@ + + & $script:BuildScriptPath -ConfigPath $configPath -ModuleVersion '2.3.4' -ConfigVersion '2026.05.12.1' *> $null + + $result = Get-Content -LiteralPath $configPath -Raw | ConvertFrom-Json + $result.ModuleVersion | Should -Be '2.3.4' + $result.ConfigVersion | Should -Be '2026.05.12.1' + $result.TestSettings[0].Id | Should -Be 'MT.1001' + } + + It 'preserves an explicitly provided empty ConfigVersion' { + $configPath = Join-Path 'TestDrive:' 'maester-config-empty-version.json' + Write-TestConfig -Path $configPath -Content @' +{ + "ModuleVersion": "1.0.0", + "ConfigVersion": "2026.05.12.1", + "GlobalSettings": { + "EmergencyAccessAccounts": [] + }, + "TestSettings": [] +} +'@ + + & $script:BuildScriptPath -ConfigPath $configPath -ModuleVersion '2.3.4' -ConfigVersion '' *> $null + + $result = Get-Content -LiteralPath $configPath -Raw | ConvertFrom-Json + $result.ModuleVersion | Should -Be '2.3.4' + $result.ConfigVersion | Should -Be '' + } + + It 'computes ConfigVersion from git commit dates in UTC when ConfigVersion is omitted' { + $repoPath = Join-Path 'TestDrive:' 'utc-config-repo' + $configPath = Join-Path $repoPath 'maester-config.json' + $null = New-Item -Path $repoPath -ItemType Directory + $repoProviderPath = (Resolve-Path -LiteralPath $repoPath).ProviderPath + + & git -C $repoProviderPath init --quiet + & git -C $repoProviderPath config user.name 'Maester Test' + & git -C $repoProviderPath config user.email 'maester-test@example.com' + + Write-TestConfig -Path $configPath -Content @' +{ + "ModuleVersion": "1.0.0", + "ConfigVersion": "", + "GlobalSettings": { + "EmergencyAccessAccounts": [] + }, + "TestSettings": [] +} +'@ + + & git -C $repoProviderPath add maester-config.json + $env:GIT_AUTHOR_DATE = '2026-01-02T02:00:00Z' + $env:GIT_COMMITTER_DATE = '2026-01-02T02:00:00Z' + & git -C $repoProviderPath commit --quiet -m 'Initial config' + + Write-TestConfig -Path $configPath -Content @' +{ + "ModuleVersion": "1.0.1", + "ConfigVersion": "", + "GlobalSettings": { + "EmergencyAccessAccounts": [] + }, + "TestSettings": [] +} +'@ + + & git -C $repoProviderPath add maester-config.json + $env:GIT_AUTHOR_DATE = '2026-01-02T03:00:00Z' + $env:GIT_COMMITTER_DATE = '2026-01-02T03:00:00Z' + & git -C $repoProviderPath commit --quiet -m 'Update config' + Remove-Item Env:\GIT_AUTHOR_DATE, Env:\GIT_COMMITTER_DATE -ErrorAction SilentlyContinue + + & $script:BuildScriptPath -ConfigPath $configPath -ModuleVersion '2.3.4' *> $null + + $result = Get-Content -LiteralPath $configPath -Raw | ConvertFrom-Json + $result.ModuleVersion | Should -Be '2.3.4' + $result.ConfigVersion | Should -Be '2026.01.02.2' + } + + It 'throws when required version field is missing' -ForEach @( + @{ + MissingField = 'ModuleVersion' + Content = @' +{ + "ConfigVersion": "", + "GlobalSettings": { + "EmergencyAccessAccounts": [] + }, + "TestSettings": [] +} +'@ + } + @{ + MissingField = 'ConfigVersion' + Content = @' +{ + "ModuleVersion": "1.0.0", + "GlobalSettings": { + "EmergencyAccessAccounts": [] + }, + "TestSettings": [] +} +'@ + } + ) { + $configPath = Join-Path 'TestDrive:' "maester-config-missing-$MissingField.json" + Write-TestConfig -Path $configPath -Content $Content + + { & $script:BuildScriptPath -ConfigPath $configPath -ModuleVersion '2.3.4' -ConfigVersion '2026.05.12.1' *> $null } | + Should -Throw -ExpectedMessage "*Required field $MissingField*" + } +}