From 1ff2c1a07f18f9f3887710f71ebeb811eeca4b82 Mon Sep 17 00:00:00 2001 From: Mike Madeja Date: Thu, 1 Oct 2026 17:04:22 -0500 Subject: [PATCH 1/2] ci: support major/minor release bumps via PR title convention The auto-release workflow always bumped the patch version on every develop->main merge, with no way to cut a major or minor release. Including "major release" or "minor release" (case-insensitive) in the PR's title now bumps that version component instead, resetting the components below it to 0. Titles without either phrase keep the existing patch-bump behavior. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/CreateRelease.yml | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/.github/workflows/CreateRelease.yml b/.github/workflows/CreateRelease.yml index 7ee89de..674f900 100644 --- a/.github/workflows/CreateRelease.yml +++ b/.github/workflows/CreateRelease.yml @@ -35,13 +35,31 @@ jobs: echo "Latest tag: $latestTag" echo "latest_tag=$latestTag" >> $GITHUB_OUTPUT - - name: Calculate next tag (patch bump) + - name: Calculate next tag (major/minor/patch bump based on PR title) id: bump + env: + PR_TITLE: ${{ github.event.pull_request.title }} run: | version="${{ steps.get_tag.outputs.latest_tag }}" version=${version#v} IFS='.' read -r major minor patch <<< "$version" - patch=$((patch + 1)) + + # Convention: include "major release" or "minor release" (case-insensitive, anywhere + # in the title) in the develop->main PR title to bump that version component instead + # of the default patch bump, e.g. "Major release: v2 config schema". + shopt -s nocasematch + if [[ "$PR_TITLE" == *"major release"* ]]; then + major=$((major + 1)); minor=0; patch=0 + echo "Bump type: major" + elif [[ "$PR_TITLE" == *"minor release"* ]]; then + minor=$((minor + 1)); patch=0 + echo "Bump type: minor" + else + patch=$((patch + 1)) + echo "Bump type: patch" + fi + shopt -u nocasematch + newTag="v$major.$minor.$patch" echo "New tag: $newTag" echo "new_tag=$newTag" >> $GITHUB_OUTPUT From 0ab26fb63feec59f6f217323e0416ada1879fb5f Mon Sep 17 00:00:00 2001 From: Mike Madeja Date: Fri, 2 Oct 2026 21:08:21 -0500 Subject: [PATCH 2/2] feat: warn when a newer PiHoleShell release is available Adds a best-effort, cached check on module import that compares the installed version against GitHub's latest release and warns if a newer one exists. Designed to never slow down or break Import-Module: - Skipped entirely for a dev/source checkout (version 0.0 or 0.0.0, depending on whether the module or its manifest was imported), so it never fires during this repo's own test suite - Cached for 24 hours so repeat imports don't hit the network or GitHub's rate limit - Opt-out via $env:PIHOLESHELL_SKIP_UPDATE_CHECK for automation - GitHub's "latest release" endpoint already excludes the dev-latest prerelease build, so that's never mistaken for a real update - Any failure (network, parsing) is swallowed silently Co-Authored-By: Claude Sonnet 5 --- PiHoleShell/PiHoleShell.psm1 | 4 +- .../Private/Test-PiHoleShellUpdate.ps1 | 60 +++++++++++++ README.md | 2 + .../Private/Test-PiHoleShellUpdate.Tests.ps1 | 86 +++++++++++++++++++ 4 files changed, 151 insertions(+), 1 deletion(-) create mode 100644 PiHoleShell/Private/Test-PiHoleShellUpdate.ps1 create mode 100644 tests/Private/Test-PiHoleShellUpdate.Tests.ps1 diff --git a/PiHoleShell/PiHoleShell.psm1 b/PiHoleShell/PiHoleShell.psm1 index 3be29b1..85b431d 100644 --- a/PiHoleShell/PiHoleShell.psm1 +++ b/PiHoleShell/PiHoleShell.psm1 @@ -47,4 +47,6 @@ Export-ModuleMember -Function @( 'Get-PiHoleNetworkGateway', 'Get-PiHoleNetworkRoute', 'Get-PiHoleNetworkInterface', 'Get-PiHoleNetworkDevice', 'Remove-PiHoleNetworkDevice', ` #DHCP 'Get-PiHoleDhcpLease', 'Remove-PiHoleDhcpLease' -) \ No newline at end of file +) + +Test-PiHoleShellUpdate -CurrentVersion $ExecutionContext.SessionState.Module.Version \ No newline at end of file diff --git a/PiHoleShell/Private/Test-PiHoleShellUpdate.ps1 b/PiHoleShell/Private/Test-PiHoleShellUpdate.ps1 new file mode 100644 index 0000000..4de3246 --- /dev/null +++ b/PiHoleShell/Private/Test-PiHoleShellUpdate.ps1 @@ -0,0 +1,60 @@ +function Test-PiHoleShellUpdate { + #INTERNAL FUNCTION + # + # Best-effort, cached check for a newer PiHoleShell release, run once at module import. + # Never throws and never meaningfully delays Import-Module: + # - Skipped entirely for a dev/source checkout: importing via the manifest leaves + # ModuleVersion at its '0.0.0' placeholder (the real version is only ever baked in at + # publish time by CreateRelease.yml), and importing the .psm1 directly - as every test in + # this repo's own suite does - gets PowerShell's auto-assigned '0.0' instead. Either way + # this never fires during this repo's own test suite, which always imports from source. + # - Skipped if $env:PIHOLESHELL_SKIP_UPDATE_CHECK is set, for automation that wants no + # network calls or output from importing the module. + # - Only calls GitHub once per $CheckIntervalHours; the result is cached to disk so repeat + # imports in the same day don't hit the network (or GitHub's unauthenticated rate limit) + # again, even though the check still runs - and still warns - on every import. + # - GitHub's "latest release" endpoint already excludes prereleases and drafts, so the + # "dev-latest" build published on every push to develop is never picked up here. + [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingEmptyCatchBlock", "", Justification = "Best-effort check - a network hiccup or unexpected response must never break module import.")] + [CmdletBinding()] + param ( + [version]$CurrentVersion, + [string]$CacheFilePath = (Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'PiHoleShell/update-check.json'), + [int]$CheckIntervalHours = 24 + ) + + if ($env:PIHOLESHELL_SKIP_UPDATE_CHECK -or $CurrentVersion -in @([version]'0.0', [version]'0.0.0')) { + return + } + + try { + $LatestVersion = $null + $Cache = $null + if (Test-Path -Path $CacheFilePath) { + $Cache = Get-Content -Path $CacheFilePath -Raw | ConvertFrom-Json + } + + if ($Cache.LastChecked -and ((Get-Date) - [datetime]$Cache.LastChecked) -lt [timespan]::FromHours($CheckIntervalHours)) { + $LatestVersion = $Cache.LatestVersion + } + else { + $Release = Invoke-RestMethod -Uri 'https://api.github.com/repos/mikemadeja/PiHoleShell/releases/latest' -TimeoutSec 3 + $LatestVersion = $Release.tag_name.TrimStart('v') + + $CacheDir = Split-Path -Path $CacheFilePath -Parent + if (-not (Test-Path -Path $CacheDir)) { + New-Item -ItemType Directory -Path $CacheDir -Force | Out-Null + } + [PSCustomObject]@{ + LastChecked = (Get-Date).ToString('o') + LatestVersion = $LatestVersion + } | ConvertTo-Json | Set-Content -Path $CacheFilePath + } + + if ([version]$LatestVersion -gt $CurrentVersion) { + Write-Warning "A newer version of PiHoleShell is available: $LatestVersion (you have $CurrentVersion). Run 'Update-Module PiHoleShell' to update." + } + } + catch { + } +} diff --git a/README.md b/README.md index ac3af28..7a4e239 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,8 @@ Install-Module -Name PiHoleShell -Scope CurrentUser Import-Module -Name PiHoleShell ``` +Importing the module checks (at most once every 24 hours, cached) whether a newer release is available and prints a warning if so. This check is skipped entirely when running from a source checkout, and can be disabled by setting `$env:PIHOLESHELL_SKIP_UPDATE_CHECK` to any value. + ## Getting an API Password 1. Log into your Pi-hole web interface, then go to **Web Interface / API** settings and select **Configure app password**. diff --git a/tests/Private/Test-PiHoleShellUpdate.Tests.ps1 b/tests/Private/Test-PiHoleShellUpdate.Tests.ps1 new file mode 100644 index 0000000..f5bb956 --- /dev/null +++ b/tests/Private/Test-PiHoleShellUpdate.Tests.ps1 @@ -0,0 +1,86 @@ +# Requires -Module Pester +Describe 'Test-PiHoleShellUpdate' { + BeforeAll { + # Dot-sourced directly rather than through the full module: this is a self-contained + # private helper with no Pi-hole server dependency, so testing it in isolation avoids + # mocking through -ModuleName for a function that never calls anything module-internal. + . .\PiHoleShell\Private\Test-PiHoleShellUpdate.ps1 + + $testCachePath = Join-Path $TestDrive 'update-check.json' + } + + BeforeEach { + if (Test-Path $testCachePath) { Remove-Item $testCachePath -Force } + Remove-Item Env:\PIHOLESHELL_SKIP_UPDATE_CHECK -ErrorAction SilentlyContinue + Mock -CommandName Invoke-RestMethod -MockWith { return @{ tag_name = 'v1.2.3' } } + } + + It 'does not check or warn for a dev-checkout version (0.0, from importing the .psm1 directly)' { + Test-PiHoleShellUpdate -CurrentVersion ([version]'0.0') -CacheFilePath $testCachePath -WarningVariable warnOut -WarningAction SilentlyContinue + + Should -Invoke Invoke-RestMethod -Times 0 -Exactly + $warnOut | Should -BeNullOrEmpty + Test-Path $testCachePath | Should -BeFalse + } + + It 'does not check or warn for a dev-checkout version (0.0.0, the manifest placeholder)' { + Test-PiHoleShellUpdate -CurrentVersion ([version]'0.0.0') -CacheFilePath $testCachePath -WarningVariable warnOut -WarningAction SilentlyContinue + + Should -Invoke Invoke-RestMethod -Times 0 -Exactly + $warnOut | Should -BeNullOrEmpty + } + + It 'does not check or warn when the opt-out environment variable is set' { + $env:PIHOLESHELL_SKIP_UPDATE_CHECK = '1' + Test-PiHoleShellUpdate -CurrentVersion ([version]'0.0.1') -CacheFilePath $testCachePath -WarningVariable warnOut -WarningAction SilentlyContinue + + Should -Invoke Invoke-RestMethod -Times 0 -Exactly + $warnOut | Should -BeNullOrEmpty + } + + It 'warns and caches the result when a newer version is available' { + Test-PiHoleShellUpdate -CurrentVersion ([version]'1.0.0') -CacheFilePath $testCachePath -WarningVariable warnOut -WarningAction SilentlyContinue + + Should -Invoke Invoke-RestMethod -Times 1 -Exactly + $warnOut | Should -Not -BeNullOrEmpty + $warnOut[0] | Should -Match '1\.2\.3' + $warnOut[0] | Should -Match '1\.0\.0' + + $cache = Get-Content $testCachePath -Raw | ConvertFrom-Json + $cache.LatestVersion | Should -Be '1.2.3' + } + + It 'does not warn when the current version is already the latest or newer' { + Test-PiHoleShellUpdate -CurrentVersion ([version]'1.2.3') -CacheFilePath $testCachePath -WarningVariable warnOut -WarningAction SilentlyContinue + $warnOut | Should -BeNullOrEmpty + + Test-PiHoleShellUpdate -CurrentVersion ([version]'2.0.0') -CacheFilePath $testCachePath -WarningVariable warnOut2 -WarningAction SilentlyContinue + $warnOut2 | Should -BeNullOrEmpty + } + + It 'reuses the cached result instead of calling the API again within the check interval' { + Test-PiHoleShellUpdate -CurrentVersion ([version]'1.0.0') -CacheFilePath $testCachePath -WarningAction SilentlyContinue + Test-PiHoleShellUpdate -CurrentVersion ([version]'1.0.0') -CacheFilePath $testCachePath -WarningVariable warnOut -WarningAction SilentlyContinue + + Should -Invoke Invoke-RestMethod -Times 1 -Exactly + $warnOut | Should -Not -BeNullOrEmpty + } + + It 'calls the API again once the cached result has expired' { + Test-PiHoleShellUpdate -CurrentVersion ([version]'1.0.0') -CacheFilePath $testCachePath -WarningAction SilentlyContinue + + $cache = Get-Content $testCachePath -Raw | ConvertFrom-Json + $cache.LastChecked = (Get-Date).AddHours(-25).ToString('o') + $cache | ConvertTo-Json | Set-Content $testCachePath + + Test-PiHoleShellUpdate -CurrentVersion ([version]'1.0.0') -CacheFilePath $testCachePath -CheckIntervalHours 24 -WarningAction SilentlyContinue + + Should -Invoke Invoke-RestMethod -Times 2 -Exactly + } + + It 'never throws when the API call fails' { + Mock -CommandName Invoke-RestMethod -MockWith { throw 'network error' } + + { Test-PiHoleShellUpdate -CurrentVersion ([version]'1.0.0') -CacheFilePath $testCachePath -WarningAction SilentlyContinue } | Should -Not -Throw + } +}