diff --git a/.codex/scripts/bump-version.ps1 b/.codex/scripts/bump-version.ps1 new file mode 100644 index 0000000..e21e069 --- /dev/null +++ b/.codex/scripts/bump-version.ps1 @@ -0,0 +1,105 @@ +param( + [Parameter(Mandatory = $true)] + [ValidatePattern('^\d+\.\d+\.\d+$')] + [string]$Version, + + [switch]$AllowEmptyChangelog +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$ScriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path +$RepoRoot = Split-Path -Parent (Split-Path -Parent $ScriptRoot) + +$ProjectPath = Join-Path $RepoRoot "Eclipse.csproj" +$ThunderstorePath = Join-Path $RepoRoot "thunderstore.toml" +$ChangelogPath = Join-Path $RepoRoot "CHANGELOG.md" + +function Set-TextPreservingUtf8Bom { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + [Parameter(Mandatory = $true)] + [string]$Value + ) + + $Bytes = [System.IO.File]::ReadAllBytes($Path) + $HasUtf8Bom = $Bytes.Length -ge 3 -and $Bytes[0] -eq 0xEF -and $Bytes[1] -eq 0xBB -and $Bytes[2] -eq 0xBF + $Encoding = [System.Text.UTF8Encoding]::new($HasUtf8Bom) + [System.IO.File]::WriteAllText($Path, $Value, $Encoding) +} + +function Update-FirstMatch { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + [Parameter(Mandatory = $true)] + [string]$Pattern, + [Parameter(Mandatory = $true)] + [string]$Replacement, + [Parameter(Mandatory = $true)] + [string]$Description + ) + + $Text = Get-Content -Raw -Path $Path + $Regex = [regex]::new($Pattern, [System.Text.RegularExpressions.RegexOptions]::Multiline) + $Match = $Regex.Match($Text) + if (-not $Match.Success) { + throw "Unable to update $Description in $Path." + } + + $Updated = $Text.Substring(0, $Match.Index) + $Regex.Replace($Match.Value, $Replacement, 1) + $Text.Substring($Match.Index + $Match.Length) + Set-TextPreservingUtf8Bom -Path $Path -Value $Updated +} + +function Update-Changelog { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + [Parameter(Mandatory = $true)] + [string]$Version, + [Parameter(Mandatory = $true)] + [bool]$AllowEmpty + ) + + $Text = Get-Content -Raw -Path $Path + + $VersionHeadingPattern = '(?m)^`' + [regex]::Escape($Version) + '`$' + if ($Text -match $VersionHeadingPattern) { + throw "CHANGELOG.md already contains a $Version entry." + } + + $UnreleasedPattern = '(?ms)^## Unreleased\s*(?.*?)(?=^`[^`]+`\s*$|^## |\z)' + $Match = [regex]::Match($Text, $UnreleasedPattern) + if (-not $Match.Success) { + throw "CHANGELOG.md must contain an '## Unreleased' section before bumping." + } + + $Body = $Match.Groups["body"].Value.Trim() + if (-not $AllowEmpty -and [string]::IsNullOrWhiteSpace($Body)) { + throw "CHANGELOG.md '## Unreleased' is empty. Use -AllowEmptyChangelog to bump anyway." + } + + $ReleasedBody = if ([string]::IsNullOrWhiteSpace($Body)) { "- No user-facing changes recorded." } else { $Body } + $VersionHeading = '`' + $Version + '`' + $Replacement = "## Unreleased`r`n`r`n$VersionHeading`r`n$ReleasedBody`r`n`r`n" + $Updated = $Text.Substring(0, $Match.Index) + $Replacement + $Text.Substring($Match.Index + $Match.Length) + Set-TextPreservingUtf8Bom -Path $Path -Value $Updated +} + +Update-FirstMatch ` + -Path $ProjectPath ` + -Pattern '[^<]+' ` + -Replacement "$Version" ` + -Description "project version" + +Update-FirstMatch ` + -Path $ThunderstorePath ` + -Pattern '^versionNumber = "[^"]+"' ` + -Replacement "versionNumber = `"$Version`"" ` + -Description "Thunderstore version" + +Update-Changelog -Path $ChangelogPath -Version $Version -AllowEmpty:$AllowEmptyChangelog.IsPresent + +Write-Host "Updated Eclipse version metadata to $Version." diff --git a/.codex/scripts/release-hygiene.tests.ps1 b/.codex/scripts/release-hygiene.tests.ps1 new file mode 100644 index 0000000..a1f6546 --- /dev/null +++ b/.codex/scripts/release-hygiene.tests.ps1 @@ -0,0 +1,181 @@ +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$ScriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path +$ReleaseNudgePath = Join-Path $ScriptRoot "release-nudge.ps1" +$BumpVersionPath = Join-Path $ScriptRoot "bump-version.ps1" + +function Assert-Equal { + param( + [Parameter(Mandatory = $true)] + [string]$Actual, + [Parameter(Mandatory = $true)] + [string]$Expected, + [Parameter(Mandatory = $true)] + [string]$Message + ) + + if ($Actual -ne $Expected) { + throw "$Message Expected '$Expected', got '$Actual'." + } +} + +function Assert-Match { + param( + [Parameter(Mandatory = $true)] + [string]$Text, + [Parameter(Mandatory = $true)] + [string]$Pattern, + [Parameter(Mandatory = $true)] + [string]$Message + ) + + if ($Text -notmatch $Pattern) { + throw "$Message Pattern '$Pattern' was not found." + } +} + +function Invoke-Git { + param( + [Parameter(Mandatory = $true)] + [string[]]$Arguments, + [Parameter(Mandatory = $true)] + [string]$WorkingDirectory + ) + + Push-Location $WorkingDirectory + try { + & git -c core.autocrlf=false @Arguments | Out-String + if ($LASTEXITCODE -ne 0) { + throw "git $($Arguments -join ' ') failed in $WorkingDirectory" + } + } + finally { + Pop-Location + } +} + +function New-FixtureRepo { + $FixtureRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("eclipse-release-hygiene-" + [guid]::NewGuid().ToString("N")) + New-Item -ItemType Directory -Path $FixtureRoot | Out-Null + New-Item -ItemType Directory -Path (Join-Path $FixtureRoot ".codex/scripts") -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $FixtureRoot "Systems") -Force | Out-Null + + Copy-Item -Path $ReleaseNudgePath -Destination (Join-Path $FixtureRoot ".codex/scripts/release-nudge.ps1") + Copy-Item -Path $BumpVersionPath -Destination (Join-Path $FixtureRoot ".codex/scripts/bump-version.ps1") + + Set-Content -Path (Join-Path $FixtureRoot "Eclipse.csproj") -Value @" + + + 1.2.3 + + +"@ -NoNewline + + Set-Content -Path (Join-Path $FixtureRoot "thunderstore.toml") -Value @" +[package] +name = "Eclipse" +versionNumber = "1.2.3" +"@ -NoNewline + + Set-Content -Path (Join-Path $FixtureRoot "CHANGELOG.md") -Value @" +## Unreleased + +- planned fix + +`1.2.3` +- previous release +"@ -NoNewline + + Set-Content -Path (Join-Path $FixtureRoot "Systems/FamiliarHealthChangeSystem.cs") -Value "class FamiliarHealthChangeSystem {}" -NoNewline + + Invoke-Git -WorkingDirectory $FixtureRoot -Arguments @("init", "-b", "main") | Out-Null + Invoke-Git -WorkingDirectory $FixtureRoot -Arguments @("config", "user.email", "codex@example.invalid") | Out-Null + Invoke-Git -WorkingDirectory $FixtureRoot -Arguments @("config", "user.name", "Codex") | Out-Null + Invoke-Git -WorkingDirectory $FixtureRoot -Arguments @("add", ".") | Out-Null + Invoke-Git -WorkingDirectory $FixtureRoot -Arguments @("commit", "-m", "baseline") | Out-Null + + return $FixtureRoot +} + +function Test-BumpVersionUpdatesEclipseMetadata { + $FixtureRoot = New-FixtureRepo + try { + & pwsh -NoProfile -File (Join-Path $FixtureRoot ".codex/scripts/bump-version.ps1") -Version "1.2.4" + if ($LASTEXITCODE -ne 0) { + throw "bump-version.ps1 exited with $LASTEXITCODE" + } + + $ProjectText = Get-Content -Raw -Path (Join-Path $FixtureRoot "Eclipse.csproj") + $ThunderstoreText = Get-Content -Raw -Path (Join-Path $FixtureRoot "thunderstore.toml") + $ChangelogText = Get-Content -Raw -Path (Join-Path $FixtureRoot "CHANGELOG.md") + + Assert-Match -Text $ProjectText -Pattern '1\.2\.4' -Message "Project version was not updated." + Assert-Match -Text $ThunderstoreText -Pattern 'versionNumber = "1\.2\.4"' -Message "Thunderstore version was not updated." + Assert-Match -Text $ChangelogText -Pattern '(?m)^## Unreleased\s+`1\.2\.4`\s+- planned fix' -Message "Changelog release entry was not created." + } + finally { + Remove-Item -LiteralPath $FixtureRoot -Recurse -Force + } +} + +function Test-ReleaseNudgeWarnOnlyFlagsUnreleasedNotes { + $FixtureRoot = New-FixtureRepo + try { + Set-Content -Path (Join-Path $FixtureRoot "Systems/FamiliarHealthChangeSystem.cs") -Value "class FamiliarHealthChangeSystem { void Changed() {} }" -NoNewline + + $Output = & pwsh -NoProfile -File (Join-Path $FixtureRoot ".codex/scripts/release-nudge.ps1") -BaseRef "main" -WarnOnly 2>&1 | Out-String + if ($LASTEXITCODE -ne 0) { + throw "release-nudge.ps1 -WarnOnly exited with $LASTEXITCODE" + } + + Assert-Match -Text $Output -Pattern 'CHANGELOG\.md has Unreleased notes' -Message "Release nudge did not flag unreleased notes." + } + finally { + Remove-Item -LiteralPath $FixtureRoot -Recurse -Force + } +} + +function Test-ReleaseNudgeBlocksWithoutWarnOnly { + $FixtureRoot = New-FixtureRepo + try { + Set-Content -Path (Join-Path $FixtureRoot "Systems/FamiliarHealthChangeSystem.cs") -Value "class FamiliarHealthChangeSystem { void Changed() {} }" -NoNewline + + $Output = & pwsh -NoProfile -File (Join-Path $FixtureRoot ".codex/scripts/release-nudge.ps1") -BaseRef "main" 2>&1 | Out-String + Assert-Equal -Actual "$LASTEXITCODE" -Expected "1" -Message "Release nudge should block when nudges exist." + Assert-Match -Text $Output -Pattern 'Eclipse' -Message "Release nudge output should name Eclipse." + } + finally { + Remove-Item -LiteralPath $FixtureRoot -Recurse -Force + } +} + +function Test-ReleaseNudgeUsesGitHubEventBeforeWhenBaseRefOmitted { + $FixtureRoot = New-FixtureRepo + $OriginalEventBefore = $env:GITHUB_EVENT_BEFORE + try { + $BeforeSha = (Invoke-Git -WorkingDirectory $FixtureRoot -Arguments @("rev-parse", "HEAD")).Trim() + Set-Content -Path (Join-Path $FixtureRoot "Systems/FamiliarHealthChangeSystem.cs") -Value "class FamiliarHealthChangeSystem { void Changed() {} }" -NoNewline + Invoke-Git -WorkingDirectory $FixtureRoot -Arguments @("add", ".") | Out-Null + Invoke-Git -WorkingDirectory $FixtureRoot -Arguments @("commit", "-m", "change system") | Out-Null + + $env:GITHUB_EVENT_BEFORE = $BeforeSha + $Output = & pwsh -NoProfile -File (Join-Path $FixtureRoot ".codex/scripts/release-nudge.ps1") -WarnOnly 2>&1 | Out-String + if ($LASTEXITCODE -ne 0) { + throw "release-nudge.ps1 -WarnOnly exited with $LASTEXITCODE" + } + + Assert-Match -Text $Output -Pattern 'Eclipse CHANGELOG\.md has Unreleased notes' -Message "Release nudge did not use GITHUB_EVENT_BEFORE when BaseRef was omitted." + } + finally { + $env:GITHUB_EVENT_BEFORE = $OriginalEventBefore + Remove-Item -LiteralPath $FixtureRoot -Recurse -Force + } +} + +Test-BumpVersionUpdatesEclipseMetadata +Test-ReleaseNudgeWarnOnlyFlagsUnreleasedNotes +Test-ReleaseNudgeBlocksWithoutWarnOnly +Test-ReleaseNudgeUsesGitHubEventBeforeWhenBaseRefOmitted + +Write-Host "release-hygiene tests passed" diff --git a/.codex/scripts/release-nudge.ps1 b/.codex/scripts/release-nudge.ps1 new file mode 100644 index 0000000..eba9d6b --- /dev/null +++ b/.codex/scripts/release-nudge.ps1 @@ -0,0 +1,146 @@ +param( + [string]$BaseRef, + + [int]$LineThreshold = 120, + + [switch]$WarnOnly +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$ScriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path +$RepoRoot = Split-Path -Parent (Split-Path -Parent $ScriptRoot) +$ChangelogPath = Join-Path $RepoRoot "CHANGELOG.md" + +function Invoke-Git { + param( + [Parameter(Mandatory = $true)] + [string[]]$Arguments + ) + + Push-Location $RepoRoot + try { + & git -c core.autocrlf=false @Arguments + } + finally { + Pop-Location + } +} + +function Write-Nudge { + param( + [Parameter(Mandatory = $true)] + [string]$Message + ) + + if ($env:GITHUB_ACTIONS -eq "true") { + Write-Host "::warning title=Release hygiene nudge::$Message" + } + else { + Write-Warning $Message + } + + $script:NudgeCount++ +} + +function Get-UnreleasedBody { + $Text = Get-Content -Raw -Path $ChangelogPath + $Match = [regex]::Match($Text, '(?ms)^## Unreleased\s*(?.*?)(?=^`[^`]+`\s*$|^## |\z)') + + if (-not $Match.Success) { + return $null + } + + return $Match.Groups["body"].Value.Trim() +} + +function Test-MeaningfulPath { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + $NormalizedPath = $Path -replace '\\', '/' + + return $NormalizedPath -match '^(BloodcraftEclipseBridge|Patches|Services|Systems|Utilities)/' ` + -or $NormalizedPath -match '^Resources/(Localization/)?[^/]+\.(cs|json)$' ` + -or $NormalizedPath -match '^\.codex/scripts/' ` + -or $NormalizedPath -match '^\.github/workflows/' ` + -or $NormalizedPath -match '^[^/]+\.(cs|csproj)$' +} + +function Test-UsableGitRef { + param( + [string]$Ref + ) + + return -not [string]::IsNullOrWhiteSpace($Ref) -and $Ref -notmatch '^0+$' +} + +if ([string]::IsNullOrWhiteSpace($BaseRef)) { + $BaseRef = if (-not [string]::IsNullOrWhiteSpace($env:GITHUB_BASE_REF)) { + "origin/$($env:GITHUB_BASE_REF)" + } + elseif (Test-UsableGitRef $env:GITHUB_EVENT_BEFORE) { + $env:GITHUB_EVENT_BEFORE + } + else { + "HEAD^" + } +} + +$MergeBase = Invoke-Git -Arguments @("merge-base", "HEAD", $BaseRef) 2>$null +if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($MergeBase)) { + Write-Host "release-nudge: unable to resolve merge-base for '$BaseRef'; skipping release hygiene gate." + exit 0 +} + +$ChangedFiles = @(Invoke-Git -Arguments @("diff", "--name-only", $MergeBase) 2>$null) +$MeaningfulFiles = @($ChangedFiles | Where-Object { Test-MeaningfulPath $_ }) + +if ($MeaningfulFiles.Count -eq 0) { + Write-Host "release-nudge: no meaningful source, script, or workflow changes detected." + exit 0 +} + +$NumstatArguments = @("diff", "--numstat", $MergeBase, "--") + $MeaningfulFiles +$Numstat = @(Invoke-Git -Arguments $NumstatArguments 2>$null) +$ChangedLines = 0 +foreach ($Line in $Numstat) { + $Parts = $Line -split "`t" + if ($Parts.Length -lt 2) { + continue + } + + $AddedLines = 0 + if ([int]::TryParse($Parts[0], [ref]$AddedLines)) { + $ChangedLines += $AddedLines + } + + $DeletedLines = 0 + if ([int]::TryParse($Parts[1], [ref]$DeletedLines)) { + $ChangedLines += $DeletedLines + } +} + +$InterfaceTouched = @($MeaningfulFiles | Where-Object { ($_ -replace '\\', '/') -match '^(BloodcraftEclipseBridge|Patches|Services|Systems)/' }).Count -gt 0 +$ChangelogChanged = @($ChangedFiles | Where-Object { $_ -eq "CHANGELOG.md" }).Count -gt 0 +$UnreleasedBody = Get-UnreleasedBody +$HasUnreleasedNotes = -not [string]::IsNullOrWhiteSpace($UnreleasedBody) +$script:NudgeCount = 0 + +if (($InterfaceTouched -or $ChangedLines -ge $LineThreshold) -and -not $ChangelogChanged -and -not $HasUnreleasedNotes) { + Write-Nudge "Meaningful Eclipse changes detected ($ChangedLines changed lines across $($MeaningfulFiles.Count) files). Consider adding CHANGELOG.md notes before release." +} + +if ($HasUnreleasedNotes) { + Write-Nudge "Eclipse CHANGELOG.md has Unreleased notes. Before a release-bound merge, consider running .codex/scripts/bump-version.ps1 so version metadata and changelog stay aligned." +} + +if ($script:NudgeCount -eq 0) { + Write-Host "release-nudge: no changelog or version-bump nudge needed." +} +elseif (-not $WarnOnly.IsPresent) { + exit 1 +} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1bac480..31894b0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -136,7 +136,7 @@ jobs: - name: Checkout uses: actions/checkout@v4 with: - fetch-depth: 1 + fetch-depth: 0 fetch-tags: false persist-credentials: false @@ -149,6 +149,12 @@ jobs: shell: bash run: bash .codex/scripts/version-metadata.sh + - name: Run release hygiene gate + shell: pwsh + env: + GITHUB_EVENT_BEFORE: ${{ github.event.before }} + run: ./.codex/scripts/release-nudge.ps1 + - name: Verify canonical files remain unchanged shell: bash run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bc73c4..21d81ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## Unreleased + +`1.3.15` +- added release hygiene helper scripts for changelog/version bump review and metadata updates +- added CI coverage for the release hygiene nudge before build verification + `1.3.14` - optional Emberglass bridge support for Bloodcraft registration and server messages - release workflow hardening for safer prerelease and Thunderstore publishing diff --git a/Eclipse.csproj b/Eclipse.csproj index 00bf1b6..14cee2a 100644 --- a/Eclipse.csproj +++ b/Eclipse.csproj @@ -4,7 +4,7 @@ enable io.zfolmt.Eclipse Eclipse - 1.3.14 + 1.3.15 true true preview diff --git a/thunderstore.toml b/thunderstore.toml index ab7a0e9..1f1ce1f 100644 --- a/thunderstore.toml +++ b/thunderstore.toml @@ -11,7 +11,7 @@ v-rising = ["oakveil-update", "mods", "client"] [package] namespace = "zfolmt" name = "Eclipse" -versionNumber = "1.3.14" +versionNumber = "1.3.15" description = "Client UI for Bloodcraft!" websiteUrl = "https://github.com/mfoltz/Eclipse" containsNsfwContent = false