Files
firmware/bin/build-winget-package.ps1
Thomas Göttgens ecd59e3120 Package meshtasticd for Windows as an MSI (#11289)
* Package meshtasticd for Windows as an MSI

Adds a --service flag connecting meshtasticd to the Service Control
Manager, a WiX MSI installing it as an auto-start LocalSystem service with
config in %ProgramData%\Meshtastic, and a CI step attaching the MSI to
releases.

* Address review comments

Bind workflow expressions to env vars in run: bodies, and build the
service status per call with an atomic checkpoint.

* Fix service stop state and CI lint

Latch the stop under a mutex so a startup report cannot walk the state
back. Ignore the new workflows in semgrep and checkov, as main_matrix
already is.

* Drop the checkov ignore for the winget workflow

Resolve the newest release inside the job instead of taking
workflow_dispatch inputs, so CKV_GHA_7 no longer fires and checkov stays
active on the file.

* Carry the MSI architecture into the winget manifest

Parse it from the asset name instead of defaulting to x64, and fail on a
multi-arch release rather than validating one at random.

* Restore release/.gitignore

* Leave the main matrix alone

Release attachment moves to the matrix rework in #11151. The MSI is still
built and uploaded as a CI artifact.

---------

Co-authored-by: Austin <vidplace7@gmail.com>
2026-07-31 04:53:52 +00:00

293 lines
10 KiB
PowerShell

#!/usr/bin/env pwsh
#Requires -Version 7.0
<#
.SYNOPSIS
Builds the meshtasticd MSI and the winget manifests that install it.
.DESCRIPTION
Writes the MSI and the three winget manifests into -OutputDirectory. The installer manifest
pins the MSI's SHA256, so the file must be published unchanged at -InstallerUrl.
WiX is required, pinned below v6, which refuses to build without accepting the OSMF EULA:
dotnet tool install --global wix --version 5.0.2
wix extension add -g WixToolset.Util.wixext/5.0.2
.EXAMPLE
platformio run -e native-windows
./bin/build-winget-package.ps1 -Validate
.EXAMPLE
./bin/build-winget-package.ps1 -Version 2.8.0 -ReleaseTag v2.8.0.1e982fa
#>
[CmdletBinding()]
param(
[string]$BinaryPath,
[string]$SampleConfigPath,
# Generate manifests for an MSI that already exists (a published release asset) instead of
# building one. WiX is not needed in that mode.
[string]$MsiPath,
[string]$Version,
[string]$ReleaseTag,
[string]$OutputDirectory,
[string]$PackageIdentifier = 'Meshtastic.Meshtasticd',
[ValidateSet('x64', 'arm64')]
[string]$Architecture = 'x64',
[string]$InstallerUrl,
[string]$ManifestVersion = '1.6.0',
[switch]$Validate
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
$WxsPath = Join-Path $RepoRoot 'packaging/windows/meshtasticd.wxs'
# Same UpgradeCode as the .wxs; also the namespace for the per-version ProductCode below.
$UpgradeCode = '8f2c6d41-5b3a-4c8e-9f27-1d0a6b4e73c5'
function Get-RepoVersion {
param([string]$PropertiesPath)
$text = Get-Content -LiteralPath $PropertiesPath -Raw
$fields = foreach ($name in 'major', 'minor', 'build') {
$match = [regex]::Match($text, "(?m)^\s*$name\s*=\s*(\d+)\s*$")
if (-not $match.Success) {
throw "Could not read '$name' from $PropertiesPath"
}
$match.Groups[1].Value
}
$short = $fields -join '.'
# Mirrors bin/readprops.py: the release tag carries a 7-char SHA, the package version does not.
# Empty on a failed or absent git, in which case the tag falls back to the bare version.
$sha = ''
try {
$sha = (git -C $RepoRoot rev-parse --short=7 HEAD 2>$null | Select-Object -First 1)
} catch {
$sha = ''
}
[pscustomobject]@{
Short = $short
Long = if ($sha) { "$short.$($sha.Trim())" } else { $short }
}
}
function New-DeterministicGuid {
param(
[Parameter(Mandatory)][string]$Namespace,
[Parameter(Mandatory)][string]$Name
)
# RFC 4122 name-based (v5) UUID. Windows Installer wants a fresh ProductCode per version for
# major upgrades to work, and deriving it keeps that reproducible across rebuilds.
$bytes = ([guid]$Namespace).ToByteArray()
# .NET lays the first three fields out little-endian; RFC 4122 hashes them big-endian.
[array]::Reverse($bytes, 0, 4)
[array]::Reverse($bytes, 4, 2)
[array]::Reverse($bytes, 6, 2)
$hash = [System.Security.Cryptography.SHA1]::HashData($bytes + [System.Text.Encoding]::UTF8.GetBytes($Name))
$guid = $hash[0..15]
$guid[6] = ($guid[6] -band 0x0F) -bor 0x50
$guid[8] = ($guid[8] -band 0x3F) -bor 0x80
[array]::Reverse($guid, 0, 4)
[array]::Reverse($guid, 4, 2)
[array]::Reverse($guid, 6, 2)
[guid][byte[]]$guid
}
function Get-WixCommand {
$wix = Get-Command wix -ErrorAction SilentlyContinue
if (-not $wix) {
$candidate = Join-Path $env:USERPROFILE '.dotnet/tools/wix.exe'
if (Test-Path -LiteralPath $candidate) {
return $candidate
}
throw "wix not found. Install it with: dotnet tool install --global wix --version 5.0.2"
}
$wix.Source
}
$buildMsi = -not $MsiPath
if ($buildMsi) {
if (-not $BinaryPath) {
$BinaryPath = Join-Path $RepoRoot '.pio/build/native-windows/meshtasticd.exe'
}
if (-not (Test-Path -LiteralPath $BinaryPath)) {
throw "meshtasticd.exe not found at '$BinaryPath'. Build it with 'platformio run -e native-windows' or pass -BinaryPath."
}
$BinaryPath = (Resolve-Path -LiteralPath $BinaryPath).Path
if (-not $SampleConfigPath) {
$SampleConfigPath = Join-Path $RepoRoot 'bin/config-dist.yaml'
}
if (-not (Test-Path -LiteralPath $SampleConfigPath)) {
throw "Sample config not found at '$SampleConfigPath'."
}
$SampleConfigPath = (Resolve-Path -LiteralPath $SampleConfigPath).Path
} elseif (-not (Test-Path -LiteralPath $MsiPath)) {
throw "MSI not found at '$MsiPath'."
} else {
$MsiPath = (Resolve-Path -LiteralPath $MsiPath).Path
}
$repoVersion = Get-RepoVersion -PropertiesPath (Join-Path $RepoRoot 'version.properties')
$versionOverridden = $PSBoundParameters.ContainsKey('Version')
if (-not $Version) { $Version = $repoVersion.Short }
if (-not $ReleaseTag) {
# An overridden -Version must not keep the checkout's tag: the MSI would not be under it.
$ReleaseTag = if ($versionOverridden) { "v$Version" } else { "v$($repoVersion.Long)" }
}
if (-not $OutputDirectory) { $OutputDirectory = Join-Path $RepoRoot 'release/winget' }
$msiName = if ($buildMsi) { "meshtasticd-$Version-$Architecture.msi" } else { Split-Path -Leaf $MsiPath }
if (-not $InstallerUrl) {
$InstallerUrl = "https://github.com/meshtastic/firmware/releases/download/$ReleaseTag/$msiName"
}
$idParts = $PackageIdentifier.Split('.')
if ($idParts.Count -lt 2) {
throw "PackageIdentifier '$PackageIdentifier' must be at least 'Publisher.Package'."
}
$manifestSegments = @('manifests', $idParts[0].Substring(0, 1).ToLowerInvariant()) + $idParts + @($Version)
$manifestDir = Join-Path $OutputDirectory ($manifestSegments -join [IO.Path]::DirectorySeparatorChar)
$null = New-Item -ItemType Directory -Path $OutputDirectory -Force
$null = New-Item -ItemType Directory -Path $manifestDir -Force
# Derived, not read back from the MSI, so -MsiPath produces the same value the build did.
$productCode = (New-DeterministicGuid -Namespace $UpgradeCode -Name "$Version-$Architecture").ToString('B').ToUpperInvariant()
if ($buildMsi) {
$msiPath = Join-Path $OutputDirectory $msiName
$wix = Get-WixCommand
& $wix build $WxsPath `
-arch $Architecture `
-ext WixToolset.Util.wixext `
-d Version=$Version `
-d ProductCode=$productCode `
-d BinaryPath=$BinaryPath `
-d ConfigPath=$SampleConfigPath `
-o $msiPath
if ($LASTEXITCODE -ne 0) {
throw "wix build failed with exit code $LASTEXITCODE. If the Util extension is missing: wix extension add -g WixToolset.Util.wixext/5.0.2"
}
} else {
$msiPath = $MsiPath
}
$sha256 = (Get-FileHash -LiteralPath $msiPath -Algorithm SHA256).Hash
$releaseDate = (Get-Date).ToString('yyyy-MM-dd')
$versionManifest = @"
# yaml-language-server: `$schema=https://aka.ms/winget-manifest.version.$ManifestVersion.schema.json
PackageIdentifier: $PackageIdentifier
PackageVersion: $Version
DefaultLocale: en-US
ManifestType: version
ManifestVersion: $ManifestVersion
"@
$installerManifest = @"
# yaml-language-server: `$schema=https://aka.ms/winget-manifest.installer.$ManifestVersion.schema.json
PackageIdentifier: $PackageIdentifier
PackageVersion: $Version
MinimumOSVersion: 10.0.17763.0
InstallerType: msi
Scope: machine
InstallModes:
- interactive
- silent
- silentWithProgress
UpgradeBehavior: install
ReleaseDate: $releaseDate
Installers:
- Architecture: $Architecture
InstallerUrl: $InstallerUrl
InstallerSha256: $sha256
ProductCode: '$productCode'
ManifestType: installer
ManifestVersion: $ManifestVersion
"@
$localeManifest = @"
# yaml-language-server: `$schema=https://aka.ms/winget-manifest.defaultLocale.$ManifestVersion.schema.json
PackageIdentifier: $PackageIdentifier
PackageVersion: $Version
PackageLocale: en-US
Publisher: Meshtastic
PublisherUrl: https://meshtastic.org
PublisherSupportUrl: https://github.com/meshtastic/firmware/issues
PackageName: meshtasticd
PackageUrl: https://github.com/meshtastic/firmware
License: GPL-3.0-only
LicenseUrl: https://github.com/meshtastic/firmware/blob/master/LICENSE
ShortDescription: Meshtastic daemon for attached LoRa radio hardware
Description: |-
meshtasticd is the native build of the Meshtastic firmware. It drives a LoRa radio attached
over SPI or a CH341 USB bridge and exposes the same TCP API as a Meshtastic device, so any
Meshtastic client can connect to it. Installs as an auto-starting Windows service reading
%ProgramData%\Meshtastic\config.yaml; without a radio configured there it runs in
simulated-radio mode.
Moniker: meshtasticd
Tags:
- daemon
- lora
- mesh
- meshtastic
- radio
ReleaseNotesUrl: https://github.com/meshtastic/firmware/releases/tag/$ReleaseTag
Documentations:
- DocumentLabel: Linux Native Docs
DocumentUrl: https://meshtastic.org/docs/software/linux/
ManifestType: defaultLocale
ManifestVersion: $ManifestVersion
"@
Set-Content -LiteralPath (Join-Path $manifestDir "$PackageIdentifier.yaml") -Value $versionManifest -Encoding utf8NoBOM
Set-Content -LiteralPath (Join-Path $manifestDir "$PackageIdentifier.installer.yaml") -Value $installerManifest -Encoding utf8NoBOM
Set-Content -LiteralPath (Join-Path $manifestDir "$PackageIdentifier.locale.en-US.yaml") -Value $localeManifest -Encoding utf8NoBOM
Write-Host "Installer $msiPath"
Write-Host "SHA256 $sha256"
Write-Host "ProductCode $productCode"
Write-Host "Manifests $manifestDir"
Write-Host "InstallerUrl $InstallerUrl"
if ($Validate) {
$winget = Get-Command winget -ErrorAction SilentlyContinue
if (-not $winget) {
Write-Warning 'winget not found on PATH; skipping manifest validation.'
} else {
winget validate --manifest $manifestDir
if ($LASTEXITCODE -ne 0) {
throw "winget validate failed with exit code $LASTEXITCODE."
}
}
}
Write-Host ''
Write-Host "Install locally (elevated): msiexec /i `"$msiPath`" /qn"
Write-Host "Then check: sc query meshtasticd"
# The banner above goes to the host; callers get the same values on the pipeline.
[pscustomobject]@{
Version = $Version
ReleaseTag = $ReleaseTag
Architecture = $Architecture
MsiPath = $msiPath
Sha256 = $sha256
ProductCode = $productCode
InstallerUrl = $InstallerUrl
ManifestDirectory = $manifestDir
}