From a57dcfdd446b14076c6e0cae9dd80907a92d0658 Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Thu, 2 Apr 2026 13:27:10 -0700 Subject: [PATCH] fix(windows): fix install script and add Windows build workflow (#281) * feat(ci): add GitHub Actions workflow for Windows build - Introduced a new workflow to automate the build process for Windows x64. - Configured steps for checking out the repository, setting up Node.js, installing Rust, caching dependencies, and building the frontend and Tauri app. - Added artifact upload steps for MSI, NSIS, and standalone CLI binaries. - Enhanced the installation script to support direct execution and improved error handling for various conditions. * feat(ci): enable Windows x64 build in release workflow Uncomment the windows-latest matrix entry, add a PowerShell step to package the CLI as a .zip (instead of .tar.gz), and require a Windows installer asset (MSI or NSIS exe) in the publish-release validation. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .github/workflows/build-windows.yml | 159 +++++++++++++++ .github/workflows/release.yml | 36 +++- scripts/install.ps1 | 294 +++++++++++++++------------- 3 files changed, 345 insertions(+), 144 deletions(-) create mode 100644 .github/workflows/build-windows.yml diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml new file mode 100644 index 000000000..e504f5629 --- /dev/null +++ b/.github/workflows/build-windows.yml @@ -0,0 +1,159 @@ +name: Build Windows + +on: + workflow_dispatch: + push: + branches: [fix/windows] + +permissions: + contents: read + +concurrency: + group: build-windows-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-windows: + name: "Desktop: Windows x64" + runs-on: windows-latest + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 1 + submodules: true + + - name: Setup Node.js 24.x + uses: actions/setup-node@v4 + with: + node-version: 24.x + cache: yarn + + - name: Install Rust (1.93.0) + uses: dtolnay/rust-toolchain@1.93.0 + with: + targets: x86_64-pc-windows-msvc + + # Skip first 7 lines of Cargo.lock (workspace package version bumps) so the key tracks dependency changes only + - name: Cargo.lock fingerprint (deps only) + id: cargo-lock-fingerprint + shell: bash + run: | + echo "hash=$(tail -n +8 Cargo.lock | openssl dgst -sha256 | awk '{print $2}')" >> "$GITHUB_OUTPUT" + + - name: Cache Cargo registry and git sources + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + key: Windows-cargo-registry-${{ steps.cargo-lock-fingerprint.outputs.hash }} + restore-keys: | + Windows-cargo-registry- + + - name: Install dependencies + run: yarn install --frozen-lockfile + + - name: Build frontend + run: yarn build + env: + NODE_ENV: production + VITE_BACKEND_URL: ${{ vars.BASE_URL }} + VITE_SENTRY_DSN: ${{ vars.VITE_SENTRY_DSN }} + VITE_DEBUG: ${{ vars.VITE_DEBUG }} + + - name: Resolve core manifest and binary names + id: core-paths + shell: bash + run: | + if [ -f "openhuman_core/Cargo.toml" ]; then + CORE_DIR="openhuman_core" + elif [ -f "rust-core/Cargo.toml" ]; then + CORE_DIR="rust-core" + elif [ -f "Cargo.toml" ] && grep -q '^name = "openhuman"' Cargo.toml; then + CORE_DIR="." + else + echo "No core Cargo manifest found" + exit 1 + fi + + SIDE_CAR_BASE="$(node -e "const fs=require('fs');const c=JSON.parse(fs.readFileSync('app/src-tauri/tauri.conf.json','utf8'));const b=(c.bundle&&Array.isArray(c.bundle.externalBin)&&c.bundle.externalBin[0])||'binaries/openhuman-core';process.stdout.write(String(b).split('/').pop());")" + CORE_BIN_NAME="${SIDE_CAR_BASE}" + + echo "core_dir=$CORE_DIR" >> "$GITHUB_OUTPUT" + echo "core_manifest=$CORE_DIR/Cargo.toml" >> "$GITHUB_OUTPUT" + echo "core_target_dir=target/x86_64-pc-windows-msvc/release" >> "$GITHUB_OUTPUT" + echo "core_bin_name=$CORE_BIN_NAME" >> "$GITHUB_OUTPUT" + echo "sidecar_base=$SIDE_CAR_BASE" >> "$GITHUB_OUTPUT" + + - name: Build sidecar core binary + shell: bash + run: | + cargo build \ + --manifest-path "$CORE_MANIFEST" \ + --release \ + --target x86_64-pc-windows-msvc \ + --bin "$CORE_BIN_NAME" + env: + CORE_MANIFEST: ${{ steps.core-paths.outputs.core_manifest }} + CORE_BIN_NAME: ${{ steps.core-paths.outputs.core_bin_name }} + + - name: Stage sidecar for Tauri bundler + shell: bash + run: | + bash scripts/release/stage-sidecar.sh \ + x86_64-pc-windows-msvc \ + "${{ steps.core-paths.outputs.core_target_dir }}" \ + "${{ steps.core-paths.outputs.core_bin_name }}" \ + "${{ steps.core-paths.outputs.sidecar_base }}" + + - name: Define Tauri configuration overrides + id: config-overrides + uses: actions/github-script@v7 + env: + BASE_URL: ${{ vars.BASE_URL }} + WITH_UPDATER: "false" + with: + script: | + const workspacePath = process.env.GITHUB_WORKSPACE.replace(/\\/g, '/'); + const prefix = workspacePath.startsWith('/') ? 'file://' : 'file:///'; + const moduleUrl = `${prefix}${workspacePath}/scripts/prepareTauriConfig.js`; + const { default: prepareTauriConfig } = await import(moduleUrl); + const config = prepareTauriConfig(); + core.setOutput('json', JSON.stringify(config)); + + - name: Build Tauri app + uses: tauri-apps/tauri-action@v0.6.2 + id: tauri-build + env: + BASE_URL: ${{ vars.BASE_URL }} + with: + projectPath: app + args: -c ${{ steps.config-overrides.outputs.json }} --target x86_64-pc-windows-msvc + includeDebug: false + includeRelease: true + + - name: Upload MSI artifact + uses: actions/upload-artifact@v4 + with: + name: windows-msi + path: | + app/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi + target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi + + - name: Upload NSIS artifact + uses: actions/upload-artifact@v4 + with: + name: windows-nsis + path: | + app/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe + target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe + + - name: Upload standalone CLI binary + uses: actions/upload-artifact@v4 + with: + name: windows-cli + path: | + ${{ steps.core-paths.outputs.core_target_dir }}/${{ steps.core-paths.outputs.core_bin_name }}.exe diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1341ca33b..0d4977194 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -195,10 +195,10 @@ jobs: args: --target x86_64-unknown-linux-gnu target: x86_64-unknown-linux-gnu artifact_suffix: ubuntu - # - platform: windows-latest - # args: --target x86_64-pc-windows-msvc - # target: x86_64-pc-windows-msvc - # artifact_suffix: windows + - platform: windows-latest + args: --target x86_64-pc-windows-msvc + target: x86_64-pc-windows-msvc + artifact_suffix: windows env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: @@ -501,7 +501,7 @@ jobs: echo "Checking staple..." xcrun stapler validate "$APP_PATH" || echo "WARNING: Staple validation failed" - - name: Package CLI tarball and upload to release + - name: Package CLI tarball and upload to release (unix) if: matrix.settings.platform != 'windows-latest' shell: bash env: @@ -527,6 +527,29 @@ jobs: "${{ needs.prepare-release.outputs.version }}" \ "${{ matrix.settings.target }}" + - name: Package CLI zip and upload to release (windows) + if: matrix.settings.platform == 'windows-latest' + shell: pwsh + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + $Version = "${{ needs.prepare-release.outputs.version }}" + $Target = "${{ matrix.settings.target }}" + $BinPath = "${{ steps.cli-paths.outputs.cli_path }}" + $ZipName = "openhuman-core-${Version}-${Target}.zip" + + $Work = New-Item -ItemType Directory -Path (Join-Path $env:TEMP "cli-pkg-$(Get-Random)") + Copy-Item $BinPath (Join-Path $Work.FullName "openhuman-core.exe") + Compress-Archive -Path (Join-Path $Work.FullName "openhuman-core.exe") -DestinationPath $ZipName + + $Hash = (Get-FileHash -Path $ZipName -Algorithm SHA256).Hash.ToLowerInvariant() + Set-Content -Path "${ZipName}.sha256" -Value $Hash -NoNewline + + Write-Host "[package-cli] Created $ZipName (sha256: $Hash)" + + gh release upload "v${Version}" $ZipName "${ZipName}.sha256" --repo tinyhumansai/openhuman --clobber + Write-Host "[package-cli] Uploaded $ZipName to v${Version}" + - name: Upload standalone CLI artifacts uses: actions/upload-artifact@v4 with: @@ -609,8 +632,7 @@ jobs: /OpenHuman_.*_x64\.dmg$/, /OpenHuman_.*_amd64\.AppImage$/, /OpenHuman_.*_amd64\.deb$/, - // Windows build is currently disabled in the matrix - // /(OpenHuman_.*_x64-setup\.exe$|OpenHuman_.*_x64.*\.msi$)/, + /(OpenHuman_.*_x64-setup\.exe$|OpenHuman_.*_x64.*\.msi$)/, ]; const missing = requiredPatterns.filter((pattern) => !names.some((name) => pattern.test(name))); diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 0c6e85a7c..8e38dc268 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -6,29 +6,34 @@ .DESCRIPTION Intended for: irm https://raw.githubusercontent.com/tinyhumansai/openhuman/main/scripts/install.ps1 | iex + + Also works when saved and run directly: + .\scripts\install.ps1 -DryRun #> -param( - [switch]$Help, - [switch]$Version, - [string]$Channel = "stable", - [switch]$DryRun -) +# Wrap in a function so `param()` works when piped via `irm | iex`. +# When piped, PowerShell cannot bind param() at the top-level scope. +function Install-OpenHuman { + param( + [switch]$Help, + [switch]$Version, + [string]$Channel = "stable", + [switch]$DryRun + ) -$ErrorActionPreference = "Stop" + $ErrorActionPreference = "Stop" -$InstallerVersion = "1.0.0" -$Repo = "tinyhumansai/openhuman" -$LatestJsonUrl = "https://github.com/$Repo/releases/latest/download/latest.json" -$LatestReleaseApiUrl = "https://api.github.com/repos/$Repo/releases/latest" + $InstallerVersion = "1.0.0" + $Repo = "tinyhumansai/openhuman" + $LatestReleaseApiUrl = "https://api.github.com/repos/$Repo/releases/latest" -function Write-Info([string]$Message) { Write-Host "-> $Message" -ForegroundColor Cyan } -function Write-Ok([string]$Message) { Write-Host "OK $Message" -ForegroundColor Green } -function Write-WarnMsg([string]$Message) { Write-Host "! $Message" -ForegroundColor Yellow } -function Write-Err([string]$Message) { Write-Host "x $Message" -ForegroundColor Red } + function Write-Info([string]$Message) { Write-Host "-> $Message" -ForegroundColor Cyan } + function Write-Ok([string]$Message) { Write-Host "OK $Message" -ForegroundColor Green } + function Write-WarnMsg([string]$Message) { Write-Host "! $Message" -ForegroundColor Yellow } + function Write-Err([string]$Message) { Write-Host "x $Message" -ForegroundColor Red } -function Show-Usage { - @" + function Show-Usage { + @" OpenHuman Installer (Windows) Usage: @@ -38,146 +43,161 @@ Examples: irm https://raw.githubusercontent.com/tinyhumansai/openhuman/main/scripts/install.ps1 | iex .\scripts\install.ps1 -DryRun "@ -} + } -if ($Help) { - Show-Usage - exit 0 -} + if ($Help) { + Show-Usage + return + } -if ($Version) { - Write-Output "openhuman-installer $InstallerVersion" - exit 0 -} + if ($Version) { + Write-Output "openhuman-installer $InstallerVersion" + return + } -if ($Channel -ne "stable") { - Write-Err "Only -Channel stable is currently supported." - exit 1 -} + if ($Channel -ne "stable") { + Write-Err "Only -Channel stable is currently supported." + return + } -if ($env:OS -ne "Windows_NT") { - Write-Err "This installer is for Windows only." - exit 1 -} + if ($env:OS -ne "Windows_NT") { + Write-Err "This installer is for Windows only." + return + } -$arch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToLowerInvariant() -if ($arch -notin @("x64", "amd64")) { - Write-Err "Unsupported architecture: $arch (Windows x64 required)." - exit 1 -} + # Detect architecture — use environment variable as primary (always available), + # fall back to .NET RuntimeInformation for newer PowerShell versions. + $arch = $env:PROCESSOR_ARCHITECTURE + if (-not $arch) { + try { + $arch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() + } catch { + $arch = "" + } + } + $arch = "$arch".ToLowerInvariant() -Write-Ok "Detected platform: windows/x64" + if ($arch -notin @("x64", "amd64")) { + Write-Err "Unsupported architecture: $arch (Windows x64 required)." + return + } -$release = $null -$releaseTag = "" -$assetName = "" -$assetUrl = "" -$assetDigest = "" + Write-Ok "Detected platform: windows/x64" + + $release = $null + $releaseTag = "" + $assetName = "" + $assetUrl = "" + $assetDigest = "" + + function Select-WindowsAssetFromRelease([object]$Rel) { + $assets = @($Rel.assets) + if (-not $assets -or $assets.Count -eq 0) { + return $null + } + + $msi = $assets | Where-Object { $_.name -match 'OpenHuman_.*x64.*\.msi$' } | Select-Object -First 1 + if ($msi) { return $msi } + + $exe = $assets | Where-Object { $_.name -match 'OpenHuman_.*x64.*\.exe$' } | Select-Object -First 1 + if ($exe) { return $exe } -function Select-WindowsAssetFromRelease([object]$Rel) { - $assets = @($Rel.assets) - if (-not $assets -or $assets.Count -eq 0) { return $null } - $msi = $assets | Where-Object { $_.name -match 'OpenHuman_.*x64.*\.msi$' } | Select-Object -First 1 - if ($msi) { return $msi } - - $exe = $assets | Where-Object { $_.name -match 'OpenHuman_.*x64.*\.exe$' } | Select-Object -First 1 - if ($exe) { return $exe } - - return $null -} - -try { - $release = Invoke-RestMethod -Uri $LatestReleaseApiUrl - $releaseTag = ($release.tag_name -replace '^v', '') - $selected = Select-WindowsAssetFromRelease -Rel $release - if ($selected) { - $assetName = $selected.name - $assetUrl = $selected.browser_download_url - if ($selected.digest) { - $assetDigest = ($selected.digest -replace '^sha256:', '') + try { + $release = Invoke-RestMethod -Uri $LatestReleaseApiUrl -UseBasicParsing + $releaseTag = ($release.tag_name -replace '^v', '') + $selected = Select-WindowsAssetFromRelease -Rel $release + if ($selected) { + $assetName = $selected.name + $assetUrl = $selected.browser_download_url + if ($selected.digest) { + $assetDigest = ($selected.digest -replace '^sha256:', '') + } } + } catch { + Write-WarnMsg "Could not query release API: $($_.Exception.Message)" } -} catch { - Write-WarnMsg "Could not query release API: $($_.Exception.Message)" -} -if (-not $assetUrl) { - Write-Err "No Windows x64 installer artifact found in latest release." - Write-Err "Ensure release workflow publishes Windows MSI/EXE assets." - exit 1 -} + if (-not $assetUrl) { + Write-Err "No Windows x64 installer artifact found in latest release." + Write-Err "Ensure release workflow publishes Windows MSI/EXE assets." + return + } -Write-Ok "Resolved latest release ($releaseTag): $assetName" + Write-Ok "Resolved latest release ($releaseTag): $assetName" -$tmpFile = Join-Path $env:TEMP $assetName -if ($DryRun) { - Write-Output "DRY RUN: download $assetUrl -> $tmpFile" -} else { - Write-Info "Downloading $assetName" - Invoke-WebRequest -Uri $assetUrl -OutFile $tmpFile -} - -if ($assetDigest) { + $tmpFile = Join-Path $env:TEMP $assetName if ($DryRun) { - Write-Output "DRY RUN: verify SHA256 $assetDigest" + Write-Output "DRY RUN: download $assetUrl -> $tmpFile" } else { - $fileHash = (Get-FileHash -Path $tmpFile -Algorithm SHA256).Hash.ToLowerInvariant() - if ($fileHash -ne $assetDigest.ToLowerInvariant()) { - Write-Err "SHA256 mismatch for $assetName" - Write-Err "Expected: $assetDigest" - Write-Err "Actual: $fileHash" - exit 1 + Write-Info "Downloading $assetName" + Invoke-WebRequest -Uri $assetUrl -OutFile $tmpFile -UseBasicParsing + } + + if ($assetDigest) { + if ($DryRun) { + Write-Output "DRY RUN: verify SHA256 $assetDigest" + } else { + $fileHash = (Get-FileHash -Path $tmpFile -Algorithm SHA256).Hash.ToLowerInvariant() + if ($fileHash -ne $assetDigest.ToLowerInvariant()) { + Write-Err "SHA256 mismatch for $assetName" + Write-Err "Expected: $assetDigest" + Write-Err "Actual: $fileHash" + return + } + Write-Ok "Integrity verified (sha256)" } - Write-Ok "Integrity verified (sha256)" - } -} else { - Write-WarnMsg "No SHA256 digest available for $assetName; skipping integrity verification." -} - -if ($DryRun) { - if ($assetName -like "*.msi") { - Write-Output "DRY RUN: msiexec /i `"$tmpFile`" MSIINSTALLPERUSER=1 ALLUSERS=2 /qn /norestart" } else { - Write-Output "DRY RUN: Start-Process `"$tmpFile`" -Wait" + Write-WarnMsg "No SHA256 digest available for $assetName; skipping integrity verification." + } + + if ($DryRun) { + if ($assetName -like "*.msi") { + Write-Output "DRY RUN: msiexec /i `"$tmpFile`" MSIINSTALLPERUSER=1 ALLUSERS=2 /qn /norestart" + } else { + Write-Output "DRY RUN: Start-Process `"$tmpFile`" -Wait" + } + return + } + + Write-Info "Installing OpenHuman" + if ($assetName -like "*.msi") { + $msiArgs = "/i `"$tmpFile`" MSIINSTALLPERUSER=1 ALLUSERS=2 /qn /norestart" + $proc = Start-Process -FilePath "msiexec.exe" -ArgumentList $msiArgs -Wait -PassThru + if ($proc.ExitCode -ne 0) { + Write-Err "MSI install failed with exit code $($proc.ExitCode)." + return + } + } elseif ($assetName -like "*.exe") { + $proc = Start-Process -FilePath $tmpFile -Wait -PassThru + if ($proc.ExitCode -ne 0) { + Write-Err "Installer exited with code $($proc.ExitCode)." + return + } + } else { + Write-Err "Unsupported Windows installer type: $assetName" + return + } + + $expectedPaths = @( + "$env:LOCALAPPDATA\Programs\OpenHuman\OpenHuman.exe", + "$env:ProgramFiles\OpenHuman\OpenHuman.exe" + ) + $launchPath = $expectedPaths | Where-Object { Test-Path $_ } | Select-Object -First 1 + + Write-Output "" + Write-Output "OpenHuman is ready." + if ($launchPath) { + Write-Output "Launch: `"$launchPath`"" + Write-Output "Uninstall: Settings -> Apps -> Installed apps -> OpenHuman" + } else { + Write-WarnMsg "Could not locate installed executable automatically." + Write-Output "Try launching OpenHuman from Start Menu." + Write-Output "Uninstall: Settings -> Apps -> Installed apps -> OpenHuman" } - exit 0 } -Write-Info "Installing OpenHuman" -if ($assetName -like "*.msi") { - $args = "/i `"$tmpFile`" MSIINSTALLPERUSER=1 ALLUSERS=2 /qn /norestart" - $proc = Start-Process -FilePath "msiexec.exe" -ArgumentList $args -Wait -PassThru - if ($proc.ExitCode -ne 0) { - Write-Err "MSI install failed with exit code $($proc.ExitCode)." - exit 1 - } -} elseif ($assetName -like "*.exe") { - $proc = Start-Process -FilePath $tmpFile -Wait -PassThru - if ($proc.ExitCode -ne 0) { - Write-Err "Installer exited with code $($proc.ExitCode)." - exit 1 - } -} else { - Write-Err "Unsupported Windows installer type: $assetName" - exit 1 -} - -$expectedPaths = @( - "$env:LOCALAPPDATA\Programs\OpenHuman\OpenHuman.exe", - "$env:ProgramFiles\OpenHuman\OpenHuman.exe" -) -$launchPath = $expectedPaths | Where-Object { Test-Path $_ } | Select-Object -First 1 - -Write-Output "" -Write-Output "OpenHuman is ready." -if ($launchPath) { - Write-Output "Launch: `"$launchPath`"" - Write-Output "Uninstall: Settings -> Apps -> Installed apps -> OpenHuman" -} else { - Write-WarnMsg "Could not locate installed executable automatically." - Write-Output "Try launching OpenHuman from Start Menu." - Write-Output "Uninstall: Settings -> Apps -> Installed apps -> OpenHuman" -} +# Run the installer, forwarding any arguments passed when invoked as a file. +Install-OpenHuman @args