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