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
122 changes: 122 additions & 0 deletions .github/workflows/release-msi.yml
Original file line number Diff line number Diff line change
@@ -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."
}
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
33 changes: 33 additions & 0 deletions packaging/windows/build-msi.ps1
Original file line number Diff line number Diff line change
@@ -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")
40 changes: 40 additions & 0 deletions packaging/windows/time-tracker.spec
Original file line number Diff line number Diff line change
@@ -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,
)
41 changes: 41 additions & 0 deletions packaging/windows/time-tracker.wxs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs">
<Package
Name="Time Tracker"
Manufacturer="Midtown Technology Group LLC"
Version="$(var.Version)"
UpgradeCode="{BCC9794F-07AB-5275-94D6-D6F1E6C0651B}"
InstallerVersion="500"
Scope="perMachine">

<SummaryInformation Description="Time Tracker installer" Manufacturer="Midtown Technology Group LLC" />
<MajorUpgrade DowngradeErrorMessage="A newer version of Time Tracker is already installed." />
<MediaTemplate EmbedCab="yes" />

<StandardDirectory Id="ProgramFiles64Folder">
<Directory Id="INSTALLROOT" Name="Midtown Technology Group">
<Directory Id="INSTALLFOLDER" Name="Time Tracker" />
</Directory>
</StandardDirectory>

<Feature Id="MainFeature" Title="Time Tracker" Level="1">
<ComponentGroupRef Id="ToolComponents" />
</Feature>

<ComponentGroup Id="ToolComponents" Directory="INSTALLFOLDER">
<Component Id="ToolExecutableComponent" Guid="*">
<File Id="ToolExecutable" Source="$(var.BinDir)\time-tracker.exe" KeyPath="yes" />
</Component>
<Component Id="PathComponent" Guid="{A59B418E-CDB8-5C35-AD56-A8960DA72301}">
<CreateFolder />
<Environment
Id="ToolPathEntry"
Action="set"
Name="PATH"
Part="last"
Permanent="no"
System="yes"
Value="[INSTALLFOLDER]" />
</Component>
</ComponentGroup>
</Package>
</Wix>
12 changes: 12 additions & 0 deletions packaging/windows/time-tracker_launcher.py
Original file line number Diff line number Diff line change
@@ -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()
27 changes: 27 additions & 0 deletions scripts/get_msi_product_code.ps1
Original file line number Diff line number Diff line change
@@ -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)
Loading