yams/packaging/windows/build-msi.ps1
Trevon d0b86106d2
fix(release): ship the daemon in the MSI, unblock experimental publishing, find the Windows hang (#238)
* fix(windows): ship yams-daemon and yams-mcp-server in the MSI

The Windows release already builds both and meson install stages them next
to yams.exe, but yams.wxs only packaged yams.exe, so daemon-backed commands
and yams serve failed after an MSI install. Package both, and make
build-msi.ps1 fail when a required executable is missing from the stage or a
staged executable is not packaged.

Signed-off-by: trvon <me@trevon.dev>

* ci(release): fall back to the stable R2 and signing secrets for experimental

The experimental channel needs EXPERIMENTAL_* Cloudflare and GPG secrets that
were never created, so even a passing nightly could not publish and
repo.yamsmemory.ai/experimental/* has never existed. Fall back to the stable
token, account and key when the channel-specific secrets are unset; setting
them later still takes precedence.

Signed-off-by: trvon <me@trevon.dev>

* ci: add a manual Windows workflow that finds hanging test cases

search_service times out at 360 s on Windows in every Tests run, which keeps
experimental from going green and blocks every nightly. The meson log only
shows the timeout. This workflow builds once and runs each Catch2 case on its
own with a per-case timeout, then reports durations and the cases that hang.
It runs only on workflow_dispatch or a push to a diagnose/** branch.

Signed-off-by: trvon <me@trevon.dev>

---------

Signed-off-by: trvon <me@trevon.dev>
2026-09-30 18:11:34 -06:00

263 lines
10 KiB
PowerShell

<#
.SYNOPSIS
Builds YAMS MSI installer using WiX Toolset v4+
.DESCRIPTION
This script builds an MSI installer for YAMS from the meson install output.
It requires WiX Toolset v4+ to be installed via: dotnet tool install --global wix
.PARAMETER StageDir
Path to the meson install --destdir output directory.
Default: .\build\release\stage
.PARAMETER Version
Product version string (e.g., 0.1.0 or nightly-20251126-abc1234).
Default: extracted from meson.build or "0.0.0"
.PARAMETER OutputDir
Directory where the MSI will be created.
Default: .\build\release
.EXAMPLE
.\build-msi.ps1 -StageDir .\build\release\stage -Version 0.1.0
#>
param(
[string]$StageDir = ".\build\release\stage",
[string]$Version = "",
[string]$OutputDir = ".\build\release"
)
$ErrorActionPreference = "Stop"
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$RepoRoot = (Get-Item "$ScriptDir\..\..").FullName
# If StageDir doesn't exist, try to create it via meson install
if (-not (Test-Path $StageDir)) {
Write-Host "Stage directory not found: $StageDir" -ForegroundColor Yellow
# Try to find the build directory
$buildDir = Split-Path $StageDir -Parent
if (-not (Test-Path (Join-Path $buildDir "build.ninja"))) {
# Default to build/release
$buildDir = Join-Path $RepoRoot "build\release"
}
if (Test-Path (Join-Path $buildDir "build.ninja")) {
Write-Host "Found build at: $buildDir" -ForegroundColor Cyan
# Reconfigure with root prefix for clean MSI staging
Write-Host "Reconfiguring meson with root prefix for MSI staging..." -ForegroundColor Cyan
& meson configure $buildDir --prefix=/
if ($LASTEXITCODE -ne 0) {
Write-Warning "meson configure failed, continuing with current prefix..."
}
Write-Host "Running meson install to create stage directory..." -ForegroundColor Cyan
$stageParent = Split-Path $StageDir -Parent
if (-not (Test-Path $stageParent)) {
New-Item -ItemType Directory -Path $stageParent -Force | Out-Null
}
& meson install -C $buildDir --destdir $StageDir
if ($LASTEXITCODE -ne 0) {
Write-Error "meson install failed. Please build first: .\setup.ps1 -BuildType Release"
exit 1
}
} else {
Write-Error @"
Stage directory not found and no build available.
Please either:
1. Build first: .\setup.ps1 -BuildType Release
2. Then package: .\setup.ps1 -BuildType Release -Package
Or manually:
1. Build: meson compile -C build/release
2. Stage: meson install -C build/release --destdir build/release/stage
3. Package: .\packaging\windows\build-msi.ps1 -StageDir build/release/stage
"@
exit 1
}
}
# Verify WiX is installed
Write-Host "Checking for WiX Toolset..." -ForegroundColor Cyan
$wixPath = Get-Command wix -ErrorAction SilentlyContinue
if (-not $wixPath) {
Write-Host "WiX Toolset not found. Installing via dotnet tool..." -ForegroundColor Yellow
& dotnet tool install --global wix --version 6.0.2
if ($LASTEXITCODE -ne 0) {
Write-Error "Failed to install WiX Toolset. Please install manually: dotnet tool install --global wix --version 6.0.2"
exit 1
}
# Refresh PATH
$env:PATH = [System.Environment]::GetEnvironmentVariable("PATH", "Machine") + ";" + [System.Environment]::GetEnvironmentVariable("PATH", "User")
}
# Verify WiX version
Write-Host "WiX version:" -ForegroundColor Cyan
& wix --version
# Add WiX UI extension (pin to 6.0.2 to match WiX toolset version)
Write-Host "Adding WiX UI extension..." -ForegroundColor Cyan
& wix extension add -g WixToolset.UI.wixext/6.0.2
if ($LASTEXITCODE -ne 0) {
Write-Warning "WiX UI extension may already be installed, continuing..."
}
# Resolve stage directory
$StageDir = (Resolve-Path $StageDir -ErrorAction Stop).Path
Write-Host "Stage directory: $StageDir" -ForegroundColor Cyan
# Find yams.exe anywhere in the stage directory
# meson install with --destdir prepends destdir to prefix, creating nested paths
Write-Host "Searching for yams.exe in stage directory..." -ForegroundColor Cyan
$yamsExeFiles = Get-ChildItem -Path $StageDir -Recurse -Filter "yams.exe" -ErrorAction SilentlyContinue
if ($yamsExeFiles.Count -eq 0) {
Write-Error "yams.exe not found anywhere in stage directory: $StageDir"
exit 1
}
# Use the first found yams.exe and derive the bin directory
$yamsBin = $yamsExeFiles[0].FullName
$binDir = Split-Path $yamsBin -Parent
# The "effective stage root" is the parent of bin/
$EffectiveStageDir = Split-Path $binDir -Parent
Write-Host "Found yams.exe at: $yamsBin" -ForegroundColor Green
Write-Host "Effective stage root: $EffectiveStageDir" -ForegroundColor Cyan
# Use EffectiveStageDir for WiX (it contains bin/yams.exe)
$StageDir = $EffectiveStageDir
# Every executable the MSI ships must be staged. A missing daemon or MCP server would
# otherwise produce an installer where daemon-backed commands and `yams serve` fail.
$requiredExecutables = @("yams.exe", "yams-daemon.exe", "yams-mcp-server.exe")
foreach ($exe in $requiredExecutables) {
$exePath = Join-Path $StageDir "bin\$exe"
if (-not (Test-Path $exePath)) {
Write-Error "Required executable missing from stage: $exePath"
exit 1
}
}
# The reverse check: any executable staged in bin\ must be listed in yams.wxs, so a new
# tool cannot ship in the stage but silently drop out of the installer.
$wxsPath = Join-Path $PSScriptRoot "yams.wxs"
$wxsText = Get-Content -Raw $wxsPath
# yams-cli.exe is the build name of the CLI; meson stages it with yams.exe as an alias, and
# the MSI ships that alias as yams.exe.
$packagedAs = @{ "yams-cli.exe" = "yams.exe" }
Get-ChildItem -Path (Join-Path $StageDir "bin") -Filter "*.exe" | ForEach-Object {
$name = if ($packagedAs.ContainsKey($_.Name)) { $packagedAs[$_.Name] } else { $_.Name }
if ($wxsText -notmatch [regex]::Escape("bin\$name")) {
Write-Error "Staged executable $($_.Name) is not packaged by yams.wxs"
exit 1
}
}
$tbbDll = Join-Path $StageDir "bin\tbb12.dll"
$includeTbb = if (Test-Path $tbbDll) { "true" } else { "false" }
if ($includeTbb -eq "true") {
Write-Host "Including TBB runtime DLL: $tbbDll" -ForegroundColor Cyan
} else {
Write-Host "TBB runtime DLL not staged; skipping TBB component." -ForegroundColor Yellow
}
# Create LICENSE.rtf for WiX installer (WiX requires RTF format)
$licenseSource = Join-Path $RepoRoot "LICENSE"
$licenseRtf = Join-Path $StageDir "LICENSE.rtf"
if (Test-Path $licenseSource) {
Write-Host "Converting LICENSE to RTF format..." -ForegroundColor Cyan
$licenseText = Get-Content $licenseSource -Raw
# Simple RTF wrapper - escape special chars and wrap in RTF
$licenseText = $licenseText -replace '\\', '\\\\' -replace '\{', '\{' -replace '\}', '\}'
$rtfContent = "{\rtf1\ansi\deff0{\fonttbl{\f0 Consolas;}}\f0\fs18 " + ($licenseText -replace "`r`n", "\par`r`n" -replace "`n", "\par`r`n") + "}"
$rtfContent | Out-File -FilePath $licenseRtf -Encoding ascii
Write-Host "License RTF created at: $licenseRtf" -ForegroundColor Cyan
} else {
Write-Warning "LICENSE file not found at: $licenseSource"
}
# Extract version if not provided
if ([string]::IsNullOrEmpty($Version)) {
$mesonBuild = Join-Path $RepoRoot "meson.build"
if (Test-Path $mesonBuild) {
$content = Get-Content $mesonBuild -Raw
if ($content -match "version\s*:\s*'([^']+)'") {
$Version = $matches[1]
Write-Host "Extracted version from meson.build: $Version" -ForegroundColor Cyan
}
}
if ([string]::IsNullOrEmpty($Version)) {
$Version = "0.0.0"
Write-Host "Using default version: $Version" -ForegroundColor Yellow
}
}
# Normalize version for MSI (must be X.Y.Z.W format)
# Handle nightly-YYYYMMDD-hash format
if ($Version -match "nightly-(\d{4})(\d{2})(\d{2})-") {
$year = [int]$matches[1] - 2020 # Offset from 2020
$month = [int]$matches[2]
$day = [int]$matches[3]
$MsiVersion = "$year.$month.$day.0"
Write-Host "Normalized nightly version to MSI format: $MsiVersion" -ForegroundColor Cyan
} elseif ($Version -match "^(\d+)\.(\d+)\.(\d+)") {
$MsiVersion = "$($matches[1]).$($matches[2]).$($matches[3]).0"
} else {
$MsiVersion = "0.0.0.0"
Write-Host "Could not parse version, using: $MsiVersion" -ForegroundColor Yellow
}
# Create output directory
$OutputDir = (New-Item -ItemType Directory -Force -Path $OutputDir).FullName
$MsiName = "yams-$Version-windows-x86_64.msi"
$MsiPath = Join-Path $OutputDir $MsiName
Write-Host "`nBuilding MSI..." -ForegroundColor Cyan
Write-Host " Version: $Version (MSI: $MsiVersion)" -ForegroundColor Gray
Write-Host " Stage: $StageDir" -ForegroundColor Gray
Write-Host " Output: $MsiPath" -ForegroundColor Gray
# Build MSI using WiX
$wxsPath = Join-Path $ScriptDir "yams.wxs"
& wix build $wxsPath `
-o $MsiPath `
-d Version=$MsiVersion `
-d StageDir=$StageDir `
-d IncludeTbb=$includeTbb `
-ext WixToolset.UI.wixext
if ($LASTEXITCODE -ne 0) {
Write-Error "WiX build failed with exit code $LASTEXITCODE"
exit 1
}
# Verify MSI was created
if (Test-Path $MsiPath) {
$msiSize = (Get-Item $MsiPath).Length / 1MB
Write-Host "`nMSI created successfully!" -ForegroundColor Green
Write-Host " Path: $MsiPath" -ForegroundColor Gray
Write-Host " Size: $([math]::Round($msiSize, 2)) MB" -ForegroundColor Gray
# Generate SHA256 hash
$hash = (Get-FileHash -Path $MsiPath -Algorithm SHA256).Hash.ToLower()
Write-Host " SHA256: $hash" -ForegroundColor Gray
# Write hash to file
$hashFile = "$MsiPath.sha256"
"$hash $MsiName" | Out-File -FilePath $hashFile -Encoding utf8 -NoNewline
Write-Host " Hash file: $hashFile" -ForegroundColor Gray
} else {
Write-Error "MSI file was not created at expected path: $MsiPath"
exit 1
}
Write-Host "`nTo install:" -ForegroundColor Cyan
Write-Host " msiexec /i `"$MsiPath`"" -ForegroundColor White
Write-Host "`nTo uninstall:" -ForegroundColor Cyan
Write-Host " msiexec /x `"$MsiPath`"" -ForegroundColor White