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..c6bbc823a 100644 --- a/.github/workflows/publish-tests.yaml +++ b/.github/workflows/publish-tests.yaml @@ -21,6 +21,28 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + 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 $env: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 '${{ steps.release_tag.outputs.tag }}' + - 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..8a7fa6f09 --- /dev/null +++ b/build/Update-MaesterConfigVersion.ps1 @@ -0,0 +1,87 @@ +<# +.SYNOPSIS + Stamps the ModuleVersion and ConfigVersion fields at the top of a + maester-config.json file. + +.DESCRIPTION + 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 + 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 + 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 (-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).ProviderPath + $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('\', '/') + $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." + } + $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 { + $config = $content | ConvertFrom-Json +} catch { + throw "Input file is not valid JSON: $_" +} + +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." +} + +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." +} + +$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) +$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/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..215fefbee 100644 --- a/powershell/tests/functions/MaesterConfig.Tests.ps1 +++ b/powershell/tests/functions/MaesterConfig.Tests.ps1 @@ -1,4 +1,41 @@ 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 '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' + $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/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*" + } +} 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": ""