diff --git a/.github/workflows/release-msi.yml b/.github/workflows/release-msi.yml new file mode 100644 index 0000000..0b946d0 --- /dev/null +++ b/.github/workflows/release-msi.yml @@ -0,0 +1,122 @@ +name: Build MSI Release + +on: + release: + types: [published] + workflow_dispatch: + inputs: + version: + description: Version to package + required: true + default: 0.1.0 + +jobs: + build-msi: + runs-on: windows-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + + - name: Set package version + id: version + shell: pwsh + run: | + if ("${{ github.event_name }}" -eq "release") { + $tag = "${{ github.event.release.tag_name }}" + if (-not $tag.StartsWith("v")) { + throw "Release tags must use the vX.Y.Z format so installer URLs match release assets." + } + $version = $tag.TrimStart("v") + "tag=$tag" >> $env:GITHUB_OUTPUT + } else { + $version = "${{ inputs.version }}" + "tag=v$version" >> $env:GITHUB_OUTPUT + } + "value=$version" >> $env:GITHUB_OUTPUT + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.10" + + - name: Set up .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: "8.0.x" + + - name: Install WiX + shell: pwsh + run: dotnet tool install --global wix --version 4.* + + - name: Install build dependencies + shell: pwsh + run: | + python -m pip install --upgrade pip + python -m pip install . pyinstaller + + - name: Build MSI + shell: pwsh + run: ./packaging/windows/build-msi.ps1 -Version "${{ steps.version.outputs.value }}" + + - name: Get MSI metadata + id: msi + shell: pwsh + run: | + $sha = (Get-FileHash dist/time-tracker.msi -Algorithm SHA256).Hash.ToLower() + $productCode = ./scripts/get_msi_product_code.ps1 -Path dist/time-tracker.msi + "sha256=$sha" >> $env:GITHUB_OUTPUT + "product_code=$productCode" >> $env:GITHUB_OUTPUT + + - name: Upload MSI to release + if: github.event_name == 'release' + shell: pwsh + run: gh release upload "${{ github.event.release.tag_name }}" dist/time-tracker.msi --clobber + env: + GH_TOKEN: ${{ github.token }} + + - name: Upload workflow artifact + if: github.event_name != 'release' + uses: actions/upload-artifact@v4 + with: + name: time-tracker-msi + path: dist/time-tracker.msi + + - name: Notify mtg-winget + if: github.event_name == 'release' + shell: pwsh + env: + GH_TOKEN: ${{ secrets.MTG_WINGET_DISPATCH_TOKEN }} + run: | + $version = "${{ steps.version.outputs.value }}" + $tag = "${{ steps.version.outputs.tag }}" + $sha = "${{ steps.msi.outputs.sha256 }}" + $productCode = "${{ steps.msi.outputs.product_code }}" + if ([string]::IsNullOrWhiteSpace($env:GH_TOKEN)) { + Write-Warning "MTG_WINGET_DISPATCH_TOKEN is not configured; skipping mtg-winget dispatch." + exit 0 + } + if ([string]::IsNullOrWhiteSpace($version) -or [string]::IsNullOrWhiteSpace($tag) -or [string]::IsNullOrWhiteSpace($sha) -or [string]::IsNullOrWhiteSpace($productCode)) { + throw "Missing version, tag, SHA256, or ProductCode for mtg-winget dispatch." + } + $body = @{ + event_type = "update-package" + client_payload = @{ + package_key = "time-tracker" + version = $version + installer_url = "https://github.com/Midtown-Technology-Group/time-tracker/releases/download/$tag/time-tracker.msi" + sha256 = $sha + product_code = $productCode + } + } | ConvertTo-Json -Depth 5 + if ([string]::IsNullOrWhiteSpace($body)) { + throw "Failed to build mtg-winget dispatch payload." + } + $body | Set-Content dispatch.json + if (-not (Test-Path dispatch.json)) { + throw "Failed to write dispatch.json." + } + gh api repos/Midtown-Technology-Group/mtg-winget/dispatches --method POST --input dispatch.json + if ($LASTEXITCODE -ne 0) { + throw "mtg-winget repository dispatch failed." + } diff --git a/README.md b/README.md index 6a4ce83..74948c0 100644 --- a/README.md +++ b/README.md @@ -151,3 +151,7 @@ AGPL-3.0 - Open source, always. --- Built by [Midtown Technology Group](https://midtowntg.com) for MSP workflows. + +## Windows MSI + +Tagged releases build a per-machine Windows MSI that installs `time-tracker.exe` under `Program Files` and adds that install directory to the system PATH. Installing or uninstalling the MSI requires an elevated prompt. diff --git a/packaging/windows/build-msi.ps1 b/packaging/windows/build-msi.ps1 new file mode 100644 index 0000000..22f2b6d --- /dev/null +++ b/packaging/windows/build-msi.ps1 @@ -0,0 +1,33 @@ +param( + [Parameter(Mandatory = $true)] + [string]$Version +) + +$ErrorActionPreference = "Stop" + +$root = Resolve-Path (Join-Path $PSScriptRoot "..\..") +$distDir = Join-Path $root "dist" +$buildDir = Join-Path $root "build" + +if (Test-Path $distDir) { + Remove-Item -Recurse -Force $distDir +} +if (Test-Path $buildDir) { + Remove-Item -Recurse -Force $buildDir +} + +python -m PyInstaller --clean --noconfirm (Join-Path $root "packaging\windows\time-tracker.spec") +if ($LASTEXITCODE -ne 0 -or -not (Test-Path (Join-Path $distDir "time-tracker.exe"))) { + throw "PyInstaller failed to produce dist\time-tracker.exe." +} + +# WiX is installed in CI with: dotnet tool install --global wix --version 4.* +$env:PATH = "$env:USERPROFILE\.dotnet\tools;$env:PATH" +if (-not (Get-Command wix -ErrorAction SilentlyContinue)) { + throw "WiX CLI was not found. Install it with: dotnet tool install --global wix --version 4.*" +} +wix build ` + (Join-Path $root "packaging\windows\time-tracker.wxs") ` + -d Version=$Version ` + -d BinDir=$distDir ` + -o (Join-Path $distDir "time-tracker.msi") diff --git a/packaging/windows/time-tracker.spec b/packaging/windows/time-tracker.spec new file mode 100644 index 0000000..97cc931 --- /dev/null +++ b/packaging/windows/time-tracker.spec @@ -0,0 +1,40 @@ +import os +from pathlib import Path + +from PyInstaller.utils.hooks import collect_data_files, collect_submodules + +project_root = Path(SPECPATH).resolve().parents[1] +datas = collect_data_files("time_tracker") +hiddenimports = collect_submodules("time_tracker") + +use_upx = os.environ.get("BUILD_USE_UPX", "").lower() in {"1", "true", "yes"} + +a = Analysis( + [str(project_root / "packaging" / "windows" / "time-tracker_launcher.py")], + pathex=[str(project_root / "src")], + binaries=[], + datas=datas, + hiddenimports=hiddenimports, + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name="time-tracker", + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=use_upx, + upx_exclude=[], + runtime_tmpdir=None, + console=True, +) diff --git a/packaging/windows/time-tracker.wxs b/packaging/windows/time-tracker.wxs new file mode 100644 index 0000000..112ae5d --- /dev/null +++ b/packaging/windows/time-tracker.wxs @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packaging/windows/time-tracker_launcher.py b/packaging/windows/time-tracker_launcher.py new file mode 100644 index 0000000..6fb78af --- /dev/null +++ b/packaging/windows/time-tracker_launcher.py @@ -0,0 +1,12 @@ +from importlib.metadata import entry_points + +def main() -> None: + eps = entry_points(group="console_scripts") + for ep in eps: + if ep.name == "time-tracker": + ep.load()() + return + raise SystemExit("Console script entry point not found: time-tracker") + +if __name__ == "__main__": + main() diff --git a/scripts/get_msi_product_code.ps1 b/scripts/get_msi_product_code.ps1 new file mode 100644 index 0000000..2e81ba4 --- /dev/null +++ b/scripts/get_msi_product_code.ps1 @@ -0,0 +1,27 @@ +param( + [Parameter(Mandatory = $true)] + [string]$Path +) + +$ErrorActionPreference = "Stop" + +$resolvedPath = Resolve-Path $Path +$installer = New-Object -ComObject WindowsInstaller.Installer +try { + $database = $installer.GetType().InvokeMember( + "OpenDatabase", + "InvokeMethod", + $null, + $installer, + @($resolvedPath.Path, 0) + ) +} catch { + throw "Failed to open MSI database at '$($resolvedPath.Path)': $($_.Exception.Message)" +} +$view = $database.OpenView("SELECT Value FROM Property WHERE Property = 'ProductCode'") +$view.Execute() +$record = $view.Fetch() +if ($null -eq $record) { + throw "ProductCode not found in MSI: $($resolvedPath.Path)" +} +$record.StringData(1)