Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions .github/workflows/CreateRelease.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion PiHoleShell/PiHoleShell.psm1
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,6 @@ Export-ModuleMember -Function @(
'Get-PiHoleNetworkGateway', 'Get-PiHoleNetworkRoute', 'Get-PiHoleNetworkInterface', 'Get-PiHoleNetworkDevice', 'Remove-PiHoleNetworkDevice', `
#DHCP
'Get-PiHoleDhcpLease', 'Remove-PiHoleDhcpLease'
)
)

Test-PiHoleShellUpdate -CurrentVersion $ExecutionContext.SessionState.Module.Version
60 changes: 60 additions & 0 deletions PiHoleShell/Private/Test-PiHoleShellUpdate.ps1
Original file line number Diff line number Diff line change
@@ -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 {
}
}
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**.
Expand Down
86 changes: 86 additions & 0 deletions tests/Private/Test-PiHoleShellUpdate.Tests.ps1
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading