Skip to content
Merged
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
78 changes: 78 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
name: build & release

# Builds the standalone exe (and the Inno Setup installer) on Windows. On a
# version tag (v*) it publishes a GitHub Release with both attached, so cutting
# a release is just: git tag v1.0.1 && git push --tags
on:
push:
branches: [main]
tags: ["v*"]
pull_request:
workflow_dispatch:

permissions:
contents: write

jobs:
build:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Install build tooling (PyInstaller + pybind11)
run: pip install -e ".[build]"

- name: Build native C++ extension in place
# Reuses the env's pybind11; compile failures degrade to the pure-Python
# walker (see setup.py), so this never blocks the build.
run: pip install -e . --no-build-isolation

- name: Build standalone exe
run: pyinstaller storageanalyzer.spec --noconfirm

- name: Smoke-test the exe
shell: pwsh
run: |
$exe = "dist\storageanalyzer.exe"
& $exe --version
$vi = (Get-Item $exe).VersionInfo
if ($vi.ProductName -ne "StorageAnalyzer") { throw "missing version resource" }
$t = Join-Path $env:RUNNER_TEMP "satest"
New-Item -ItemType Directory -Force -Path $t | Out-Null
Set-Content -Path (Join-Path $t "f.txt") -Value ("x" * 4096)
& $exe $t --no-open -o (Join-Path $env:RUNNER_TEMP "report.html")
if (-not (Test-Path (Join-Path $env:RUNNER_TEMP "report.html"))) { throw "no report produced" }

- name: Build installer (Inno Setup)
shell: pwsh
run: |
choco install innosetup --no-progress -y
$version = (Select-String -Path src\storageanalyzer\__init__.py `
-Pattern '__version__\s*=\s*"([^"]+)"').Matches[0].Groups[1].Value
& "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe" `
"/DMyAppVersion=$version" installer\storageanalyzer.iss

- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: storageanalyzer-windows
path: |
dist/storageanalyzer.exe
dist/StorageAnalyzer-Setup-*.exe
if-no-files-found: error

- name: Publish GitHub Release
if: startsWith(github.ref, 'refs/tags/v')
shell: pwsh
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release create "$env:GITHUB_REF_NAME" `
dist\storageanalyzer.exe `
(Get-ChildItem dist\StorageAnalyzer-Setup-*.exe).FullName `
--title "StorageAnalyzer $env:GITHUB_REF_NAME" `
--generate-notes
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 Cameron Crow

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
47 changes: 44 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,13 +62,54 @@ pip install -e ".[build]"

`build-exe.ps1` compiles the native C++ walker, then runs PyInstaller against
`storageanalyzer.spec` to produce a one-file exe (~8.5 MB) with the native
walker and the HTML template bundled in. PyInstaller caches its analysis in
`build\`, so re-runs are quick — pure-Python source edits need no recompile,
only `walker.cpp` changes do. Pass `-Clean` to force a full rebuild.
walker and the HTML template bundled in. The exe is a proper Windows artifact:
it carries the application **icon** and a **version resource** (right-click →
Properties → Details shows ProductName, version, company, and copyright), both
derived from `storageanalyzer.__version__` so they never drift. PyInstaller
caches its analysis in `build\`, so re-runs are quick — pure-Python source edits
need no recompile, only `walker.cpp` changes do. Pass `-Clean` to force a full
rebuild.

The exe is fully portable: copy `dist\storageanalyzer.exe` anywhere and run it.
It takes the same arguments as the `storageanalyzer` command below.

### Build a Windows installer

For a friendlier distribution — a per-user installer that adds `storageanalyzer`
to your PATH and registers an uninstaller — build the [Inno Setup](https://jrsoftware.org/isinfo.php)
package:

```powershell
# one-time: install Inno Setup
winget install JRSoftware.InnoSetup

# build -- produces dist\StorageAnalyzer-Setup-<version>.exe
.\build-installer.ps1
```

`build-installer.ps1` builds the exe first, then compiles
`installer\storageanalyzer.iss`. The installer needs no admin rights (installs
under `%LOCALAPPDATA%\Programs`), offers an opt-in "add to PATH" task, and shows
up in *Apps & features*. If Inno Setup isn't installed the script still builds
the exe and exits with a note — the one-file exe is shippable on its own.

### Automated releases

`.github/workflows/release.yml` builds the exe and the installer on Windows for
every push and PR, and **publishes a GitHub Release with both attached whenever
a version tag is pushed**:

```powershell
git tag v1.0.1
git push --tags
```

The icon and version metadata are regenerated from `__version__` on every build,
so the only thing to bump for a release is `__version__` in
`src/storageanalyzer/__init__.py` (and the matching `version` in
`pyproject.toml`). The application icon lives at `packaging/storageanalyzer.ico`;
regenerate or tweak it with `python packaging/make_icon.py` (requires Pillow).

## Usage

```powershell
Expand Down
66 changes: 66 additions & 0 deletions build-installer.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<#
build-installer.ps1 -- build dist\StorageAnalyzer-Setup-<version>.exe

Steps:
1. Build the standalone exe (delegates to build-exe.ps1).
2. Compile installer\storageanalyzer.iss with Inno Setup's ISCC, stamping
the version read from storageanalyzer.__version__.

Inno Setup is required for step 2. If ISCC.exe is not found the script builds
the exe and then exits with guidance instead of failing hard -- the one-file
exe is already a complete, shippable artifact on its own.

Install Inno Setup: winget install JRSoftware.InnoSetup
or: choco install innosetup

Usage:
.\build-installer.ps1 # build exe + installer
.\build-installer.ps1 -Clean # force a full exe rebuild first
#>
param([switch]$Clean)

$ErrorActionPreference = "Stop"
Set-Location $PSScriptRoot

# --- 1. build the exe ------------------------------------------------------
& "$PSScriptRoot\build-exe.ps1" @(if ($Clean) { "-Clean" })
if ($LASTEXITCODE -ne 0) { Write-Host "exe build failed" -ForegroundColor Red; exit 1 }

# --- version (single source of truth: __init__.py __version__) -------------
$initPy = Join-Path $PSScriptRoot "src\storageanalyzer\__init__.py"
$m = Select-String -Path $initPy -Pattern '__version__\s*=\s*["'']([^"'']+)["'']'
$version = $m.Matches[0].Groups[1].Value
if (-not $version) { Write-Host "could not read __version__" -ForegroundColor Red; exit 1 }

# --- 2. locate ISCC --------------------------------------------------------
$iscc = (Get-Command ISCC.exe -ErrorAction SilentlyContinue).Source
if (-not $iscc) {
foreach ($p in @(
"${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe",
"$env:ProgramFiles\Inno Setup 6\ISCC.exe")) {
if (Test-Path $p) { $iscc = $p; break }
}
}
if (-not $iscc) {
Write-Host ""
Write-Host "Inno Setup (ISCC.exe) not found -- skipping the installer." -ForegroundColor Yellow
Write-Host "The standalone exe is ready at dist\storageanalyzer.exe." -ForegroundColor Yellow
Write-Host "To build the installer, install Inno Setup and re-run:" -ForegroundColor Yellow
Write-Host " winget install JRSoftware.InnoSetup" -ForegroundColor Yellow
exit 0
}

# --- 3. compile the installer ----------------------------------------------
Write-Host "Compiling installer (version $version) with $iscc ..." -ForegroundColor Cyan
& $iscc "/DMyAppVersion=$version" "$PSScriptRoot\installer\storageanalyzer.iss"
if ($LASTEXITCODE -ne 0) { Write-Host "ISCC failed" -ForegroundColor Red; exit 1 }

$setup = Join-Path $PSScriptRoot "dist\StorageAnalyzer-Setup-$version.exe"
if (Test-Path $setup) {
$size = "{0:N1} MB" -f ((Get-Item $setup).Length / 1MB)
Write-Host ""
Write-Host "Built: $setup ($size)" -ForegroundColor Green
} else {
Write-Host "ISCC reported success but $setup was not found." -ForegroundColor Red
exit 1
}
123 changes: 123 additions & 0 deletions installer/storageanalyzer.iss
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
; Inno Setup script for StorageAnalyzer.
;
; Wraps the standalone dist\storageanalyzer.exe in a per-user installer that
; * installs to %LOCALAPPDATA%\Programs\StorageAnalyzer (no admin needed),
; * optionally adds that folder to the user PATH so `storageanalyzer` works
; from any terminal,
; * registers a proper uninstaller (Apps & features),
; * stamps publisher / version metadata.
;
; The version is injected by build-installer.ps1 from storageanalyzer.__version__;
; the default below is only used if you run ISCC by hand.
;
; Build: ISCC.exe /DMyAppVersion=1.0.0 installer\storageanalyzer.iss
; Or just: .\build-installer.ps1 (builds the exe first, then this)

#ifndef MyAppVersion
#define MyAppVersion "1.0.0"
#endif

#define MyAppName "StorageAnalyzer"
#define MyAppPublisher "Cameron Crow"
#define MyAppURL "https://github.com/CameronCrow/StorageAnalyzer"
#define MyAppExeName "storageanalyzer.exe"

[Setup]
; A stable AppId keeps upgrades/uninstall coherent across versions -- do not change.
AppId={{75FD88CC-FE15-46C9-894F-5A5CABD9E1A5}
AppName={#MyAppName}
AppVersion={#MyAppVersion}
AppVerName={#MyAppName} {#MyAppVersion}
AppPublisher={#MyAppPublisher}
AppPublisherURL={#MyAppURL}
AppSupportURL={#MyAppURL}
AppUpdatesURL={#MyAppURL}/releases
DefaultDirName={autopf}\{#MyAppName}
DefaultGroupName={#MyAppName}
DisableProgramGroupPage=yes
DisableDirPage=auto
; Per-user install -- no UAC prompt, installs under %LOCALAPPDATA%\Programs.
PrivilegesRequired=lowest
ChangesEnvironment=yes
LicenseFile=..\LICENSE
OutputDir=..\dist
OutputBaseFilename=StorageAnalyzer-Setup-{#MyAppVersion}
SetupIconFile=..\packaging\storageanalyzer.ico
UninstallDisplayIcon={app}\{#MyAppExeName}
Compression=lzma2
SolidCompression=yes
WizardStyle=modern
ArchitecturesAllowed=x64compatible
ArchitecturesInstallIn64BitMode=x64compatible

[Languages]
Name: "english"; MessagesFile: "compiler:Default.isl"

[Tasks]
Name: "addtopath"; Description: "Add StorageAnalyzer to my PATH (run ""storageanalyzer"" from any terminal)"; Flags: checkedonce

[Files]
Source: "..\dist\{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion

[Icons]
Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
Name: "{group}\{#MyAppName} on GitHub"; Filename: "{#MyAppURL}"
Name: "{group}\Uninstall {#MyAppName}"; Filename: "{uninstallexe}"

[Code]
const
EnvironmentKey = 'Environment';

procedure EnvAddPath(Path: string);
var
Paths: string;
begin
{ Read the existing user PATH (empty if unset). }
if not RegQueryStringValue(HKEY_CURRENT_USER, EnvironmentKey, 'Path', Paths) then
Paths := '';

{ Skip if already present (delimited, case-insensitive). }
if Pos(';' + Uppercase(Path) + ';', ';' + Uppercase(Paths) + ';') > 0 then
exit;

{ Append, normalising the trailing delimiter. }
if Paths = '' then
Paths := Path
else if Copy(Paths, Length(Paths), 1) = ';' then
Paths := Paths + Path
else
Paths := Paths + ';' + Path;

if RegWriteStringValue(HKEY_CURRENT_USER, EnvironmentKey, 'Path', Paths) then
Log('PATH: added ' + Path)
else
Log('PATH: FAILED to add ' + Path);
end;

procedure EnvRemovePath(Path: string);
var
Paths: string;
P: Integer;
begin
if not RegQueryStringValue(HKEY_CURRENT_USER, EnvironmentKey, 'Path', Paths) then
exit;

P := Pos(';' + Uppercase(Path) + ';', ';' + Uppercase(Paths) + ';');
if P = 0 then
exit;

Delete(Paths, P - 1, Length(Path) + 1);
RegWriteStringValue(HKEY_CURRENT_USER, EnvironmentKey, 'Path', Paths);
end;

procedure CurStepChanged(CurStep: TSetupStep);
begin
if (CurStep = ssPostInstall) and WizardIsTaskSelected('addtopath') then
EnvAddPath(ExpandConstant('{app}'));
end;

procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep);
begin
if CurUninstallStep = usPostUninstall then
EnvRemovePath(ExpandConstant('{app}'));
end;
Loading
Loading