From f3ae50c7888748dbe4049402e45d69a6f76a0f13 Mon Sep 17 00:00:00 2001 From: Tom Butcher Date: Tue, 28 Jul 2026 22:37:38 +0100 Subject: [PATCH] Add Windows MSI packaging and finalize desktop artifacts script - Introduced a new Windows Installer XML (WXS) file for packaging the Farm Control Server as an MSI. - Added a PowerShell script to build the MSI from the WXS file. - Implemented a new script to finalize desktop artifacts, handling the publication of macOS and Windows packages. - Updated the Linux build script to improve artifact naming and organization. - Enhanced the Electrobun configuration to include a post-package script for finalizing desktop artifacts. --- Jenkinsfile | 4 +- electrobun.config.ts | 3 + packaging/windows/farmcontrol-server.wxs | 48 +++++ scripts/build-linux-packages.sh | 36 +++- scripts/build-windows-msi.ps1 | 66 +++++++ scripts/finalize-desktop-artifacts.mjs | 232 +++++++++++++++++++++++ scripts/release-artifact-utils.mjs | 17 ++ 7 files changed, 402 insertions(+), 4 deletions(-) create mode 100644 packaging/windows/farmcontrol-server.wxs create mode 100644 scripts/build-windows-msi.ps1 create mode 100644 scripts/finalize-desktop-artifacts.mjs create mode 100644 scripts/release-artifact-utils.mjs diff --git a/Jenkinsfile b/Jenkinsfile index ae562b6..be83a4d 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -62,7 +62,7 @@ def buildLinux() { } stage('Archive Artifacts (Ubuntu)') { - archiveArtifacts artifacts: 'app_dist/**/*.deb, app_dist/**/*.rpm', fingerprint: true + archiveArtifacts artifacts: 'app_dist/farmcontrol-server-*', fingerprint: true } } finally { cleanWs() @@ -104,7 +104,7 @@ def buildOnLabel(label, buildCommand) { } stage("Archive Artifacts (${label})") { - archiveArtifacts artifacts: 'app_dist/**/farmcontrol-server-*', fingerprint: true + archiveArtifacts artifacts: 'app_dist/farmcontrol-server-*', fingerprint: true } } } diff --git a/electrobun.config.ts b/electrobun.config.ts index 27b5b27..483550c 100644 --- a/electrobun.config.ts +++ b/electrobun.config.ts @@ -31,4 +31,7 @@ export default { runtime: { exitOnLastWindowClosed: false, }, + scripts: { + postPackage: "bun scripts/finalize-desktop-artifacts.mjs", + }, }; diff --git a/packaging/windows/farmcontrol-server.wxs b/packaging/windows/farmcontrol-server.wxs new file mode 100644 index 0000000..571a23e --- /dev/null +++ b/packaging/windows/farmcontrol-server.wxs @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + NOT Installed + + + diff --git a/scripts/build-linux-packages.sh b/scripts/build-linux-packages.sh index f0d9454..588b47c 100755 --- a/scripts/build-linux-packages.sh +++ b/scripts/build-linux-packages.sh @@ -2,8 +2,11 @@ set -euo pipefail version="$(bun --eval "console.log(JSON.parse(await Bun.file('package.json').text()).version)")" -output_dir="app_dist/linux" -binary="${output_dir}/farmcontrol-server" +arch="x64" +output_dir="app_dist" +binary="${output_dir}/linux/farmcontrol-server" +deb_name="farmcontrol-server-${version}-${arch}.deb" +rpm_name="farmcontrol-server-${version}-${arch}.rpm" common_fpm_args=( -f @@ -20,3 +23,32 @@ common_fpm_args=( fpm -t deb "${common_fpm_args[@]}" fpm -t rpm "${common_fpm_args[@]}" + +deb_source="${output_dir}/farmcontrol-server_${version}_amd64.deb" +rpm_source="${output_dir}/farmcontrol-server-${version}-1.x86_64.rpm" + +if [[ ! -f "${deb_source}" ]]; then + deb_source="$(find "${output_dir}" -maxdepth 1 -name 'farmcontrol-server_*_amd64.deb' | head -n 1)" +fi + +if [[ ! -f "${rpm_source}" ]]; then + rpm_source="$(find "${output_dir}" -maxdepth 1 -name 'farmcontrol-server-*.x86_64.rpm' | head -n 1)" +fi + +if [[ -z "${deb_source}" || ! -f "${deb_source}" ]]; then + echo "Could not find generated .deb package in ${output_dir}" >&2 + exit 1 +fi + +if [[ -z "${rpm_source}" || ! -f "${rpm_source}" ]]; then + echo "Could not find generated .rpm package in ${output_dir}" >&2 + exit 1 +fi + +mv "${deb_source}" "${output_dir}/${deb_name}" +mv "${rpm_source}" "${output_dir}/${rpm_name}" + +rm -rf "${output_dir}/linux" + +echo "Published ${output_dir}/${deb_name}" +echo "Published ${output_dir}/${rpm_name}" diff --git a/scripts/build-windows-msi.ps1 b/scripts/build-windows-msi.ps1 new file mode 100644 index 0000000..9ebc716 --- /dev/null +++ b/scripts/build-windows-msi.ps1 @@ -0,0 +1,66 @@ +param( + [Parameter(Mandatory = $true)] + [string]$SetupExe, + + [Parameter(Mandatory = $true)] + [string]$OutputMsi, + + [Parameter(Mandatory = $true)] + [string]$Version +) + +$ErrorActionPreference = "Stop" + +function Find-WixTool($toolName) { + $command = Get-Command $toolName -ErrorAction SilentlyContinue + if ($command) { + return $command.Source + } + + $searchRoots = @( + "${env:ProgramFiles(x86)}\WiX Toolset v3.14\bin", + "${env:ProgramFiles(x86)}\WiX Toolset v3.11\bin", + "${env:ProgramFiles}\WiX Toolset v3.14\bin", + "${env:ProgramFiles}\WiX Toolset v3.11\bin" + ) + + foreach ($root in $searchRoots) { + $candidate = Join-Path $root "$toolName.exe" + if (Test-Path $candidate) { + return $candidate + } + } + + throw "Could not find $toolName. Install WiX Toolset v3.11 or v3.14 on the Windows build agent." +} + +$rootDir = Split-Path -Parent $PSScriptRoot +$wxsPath = Join-Path $rootDir "packaging/windows/farmcontrol-server.wxs" +$workDir = Join-Path $env:TEMP "farmcontrol-server-msi" +$wixObj = Join-Path $workDir "farmcontrol-server.wixobj" + +if (Test-Path $workDir) { + Remove-Item $workDir -Recurse -Force +} +New-Item -ItemType Directory -Path $workDir | Out-Null + +$candle = Find-WixTool "candle" +$light = Find-WixTool "light" + +& $candle ` + -nologo ` + -out $wixObj ` + -dVersion=$Version ` + -dSetupExe=$SetupExe ` + $wxsPath + +& $light ` + -nologo ` + -out $OutputMsi ` + $wixObj + +if (-not (Test-Path $OutputMsi)) { + throw "MSI was not created at $OutputMsi" +} + +Write-Host "Created MSI at $OutputMsi" diff --git a/scripts/finalize-desktop-artifacts.mjs b/scripts/finalize-desktop-artifacts.mjs new file mode 100644 index 0000000..f1c48bc --- /dev/null +++ b/scripts/finalize-desktop-artifacts.mjs @@ -0,0 +1,232 @@ +import { + cpSync, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + rmSync, + statSync, +} from "node:fs"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { + getReleaseArch, + getReleaseArtifactName, + getReleaseVersion, +} from "./release-artifact-utils.mjs"; + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const packageJson = JSON.parse( + readFileSync(path.join(rootDir, "package.json"), "utf8"), +); +const version = getReleaseVersion(packageJson); +const artifactDir = path.join(rootDir, "app_dist"); +const identifier = "com.tombutcher.farmcontrolserver"; + +function walkFiles(dir) { + const files = []; + if (!existsSync(dir)) { + return files; + } + + for (const entry of readdirSync(dir)) { + const fullPath = path.join(dir, entry); + const stats = statSync(fullPath); + if (stats.isDirectory()) { + files.push(...walkFiles(fullPath)); + } else { + files.push(fullPath); + } + } + + return files; +} + +function findByExtension(root, extension) { + return walkFiles(root).find((filePath) => + filePath.toLowerCase().endsWith(extension.toLowerCase()), + ); +} + +function findMacAppBundle() { + const buildDir = path.join(rootDir, "build"); + if (!existsSync(buildDir)) { + return null; + } + + for (const entry of readdirSync(buildDir)) { + if (!entry.startsWith("stable-macos-")) { + continue; + } + + const platformDir = path.join(buildDir, entry); + for (const child of readdirSync(platformDir)) { + if (child.endsWith(".app")) { + return path.join(platformDir, child); + } + } + } + + return null; +} + +function findWindowsSetupExe() { + const buildDir = path.join(rootDir, "build"); + if (!existsSync(buildDir)) { + return null; + } + + for (const entry of readdirSync(buildDir)) { + if (!entry.startsWith("stable-win-")) { + continue; + } + + const platformDir = path.join(buildDir, entry); + const setupExe = walkFiles(platformDir).find((filePath) => + filePath.toLowerCase().endsWith("-setup.exe"), + ); + if (setupExe) { + return setupExe; + } + } + + return findByExtension(buildDir, "-Setup.exe"); +} + +function publishArtifact(sourcePath, arch, ext) { + if (!sourcePath || !existsSync(sourcePath)) { + throw new Error(`Missing source artifact for ${arch}.${ext}: ${sourcePath ?? "not found"}`); + } + + mkdirSync(artifactDir, { recursive: true }); + const destination = path.join( + artifactDir, + getReleaseArtifactName(version, arch, ext), + ); + cpSync(sourcePath, destination); + console.log(`Published ${destination}`); + return destination; +} + +function cleanArtifactDir(keepNames) { + if (!existsSync(artifactDir)) { + return; + } + + for (const entry of readdirSync(artifactDir)) { + if (keepNames.includes(entry)) { + continue; + } + + rmSync(path.join(artifactDir, entry), { recursive: true, force: true }); + } +} + +function buildMacPkg(appBundlePath, arch) { + const pkgPath = path.join( + artifactDir, + getReleaseArtifactName(version, arch, "pkg"), + ); + + const result = spawnSync( + "pkgbuild", + [ + "--component", + appBundlePath, + "/Applications", + "--install-location", + "/Applications", + "--identifier", + identifier, + "--version", + version, + pkgPath, + ], + { stdio: "inherit" }, + ); + + if (result.status !== 0) { + throw new Error(`pkgbuild failed with exit code ${result.status ?? 1}`); + } + + console.log(`Published ${pkgPath}`); + return pkgPath; +} + +function buildWindowsMsi(setupExePath, arch) { + const scriptPath = path.join(rootDir, "scripts/build-windows-msi.ps1"); + const msiPath = path.join( + artifactDir, + getReleaseArtifactName(version, arch, "msi"), + ); + const powershell = + process.env.SystemRoot + ? path.join( + process.env.SystemRoot, + "System32", + "WindowsPowerShell", + "v1.0", + "powershell.exe", + ) + : "powershell.exe"; + + const result = spawnSync( + powershell, + [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + scriptPath, + "-SetupExe", + setupExePath, + "-OutputMsi", + msiPath, + "-Version", + version, + ], + { stdio: "inherit" }, + ); + + if (result.status !== 0) { + throw new Error(`build-windows-msi.ps1 failed with exit code ${result.status ?? 1}`); + } + + console.log(`Published ${msiPath}`); + return msiPath; +} + +const published = []; + +if (process.platform === "darwin") { + const arch = getReleaseArch(); + const dmgSource = + findByExtension(artifactDir, ".dmg") ?? findByExtension(path.join(rootDir, "build"), ".dmg"); + const appBundle = findMacAppBundle(); + + if (!dmgSource) { + throw new Error("Could not find a macOS DMG artifact to publish"); + } + if (!appBundle) { + throw new Error("Could not find a macOS .app bundle to build a PKG"); + } + + published.push(publishArtifact(dmgSource, arch, "dmg")); + published.push(buildMacPkg(appBundle, arch)); +} else if (process.platform === "win32") { + const arch = "x64"; + const setupExe = findWindowsSetupExe(); + + if (!setupExe) { + throw new Error("Could not find the Windows setup executable to publish"); + } + + published.push(publishArtifact(setupExe, arch, "exe")); + published.push(buildWindowsMsi(setupExe, arch)); +} else { + console.log("finalize-desktop-artifacts: skipping unsupported platform", process.platform); + process.exit(0); +} + +cleanArtifactDir(published.map((filePath) => path.basename(filePath))); diff --git a/scripts/release-artifact-utils.mjs b/scripts/release-artifact-utils.mjs new file mode 100644 index 0000000..bc3e539 --- /dev/null +++ b/scripts/release-artifact-utils.mjs @@ -0,0 +1,17 @@ +export function getReleaseVersion(packageJson) { + return packageJson.version; +} + +export function getReleaseArch(platformArch = process.arch) { + if (platformArch === "arm64" || platformArch === "aarch64") { + return "arm64"; + } + if (platformArch === "x64" || platformArch === "amd64") { + return "x64"; + } + return platformArch; +} + +export function getReleaseArtifactName(version, arch, ext) { + return `farmcontrol-server-${version}-${arch}.${ext}`; +}