Skip to content
Open
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
177 changes: 177 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
# Release
#
# Produces the signed, single-exe installer (dist\MicroClawSetup.exe) and
# publishes it as a GitHub Release asset.
#
# build.ps1 Step 7 wraps the PyInstaller onedir in an NSIS self-extractor
# (installer\microclaw-setup.nsi) and then calls scripts\windows\sign-artifact.ps1.
# That signer is a NO-OP unless the TRUSTED_SIGNING_* / AZURE_* secrets below
# are present, so the *signed* exe is only ever produced by this workflow —
# never on PRs (which have no secrets and may run on forks).
#
# Signing the single downloaded exe is what clears Windows SmartScreen:
# files a trusted local process later extracts carry no Mark-of-the-Web and
# are not re-evaluated.
#
# Triggers:
# - push of a version tag (v*) -> publishes a release for that tag
# - manual workflow_dispatch -> builds + signs, uploads as a run artifact
name: "Release"

on:
push:
tags: ["v*"]
workflow_dispatch:
inputs:
sign:
description: "Code-sign MicroClawSetup.exe (uncheck to get an UNSIGNED exe while signing is still under discussion)"
type: boolean
default: false
publish_release:
description: "Create a GitHub Release (only meaningful on a tag)"
type: boolean
default: false

concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false

permissions:
contents: write # needed to create the release and upload assets

jobs:
build-signed:
name: Build + sign single-exe installer
runs-on: windows-latest
timeout-minutes: 60
defaults:
run:
shell: pwsh

env:
# Azure Trusted Signing — consumed by scripts\windows\sign-artifact.ps1.
# If any of the three TRUSTED_SIGNING_* values is empty the signer skips
# (no-op, exit 0) and the exe is produced UNSIGNED.
#
# Signing is enabled for tag builds, and for manual (workflow_dispatch)
# runs only when the "sign" input is checked. When disabled we pass empty
# strings so the signer no-ops and you get an unsigned MicroClawSetup.exe
# (useful while the signing approach is still under discussion).
TRUSTED_SIGNING_ENDPOINT: ${{ (github.event_name != 'workflow_dispatch' || inputs.sign) && secrets.TRUSTED_SIGNING_ENDPOINT || '' }}
TRUSTED_SIGNING_ACCOUNT: ${{ (github.event_name != 'workflow_dispatch' || inputs.sign) && secrets.TRUSTED_SIGNING_ACCOUNT || '' }}
TRUSTED_SIGNING_PROFILE: ${{ (github.event_name != 'workflow_dispatch' || inputs.sign) && secrets.TRUSTED_SIGNING_PROFILE || '' }}
# DefaultAzureCredential for the Trusted Signing client.
AZURE_TENANT_ID: ${{ (github.event_name != 'workflow_dispatch' || inputs.sign) && secrets.AZURE_TENANT_ID || '' }}
AZURE_CLIENT_ID: ${{ (github.event_name != 'workflow_dispatch' || inputs.sign) && secrets.AZURE_CLIENT_ID || '' }}
AZURE_CLIENT_SECRET: ${{ (github.event_name != 'workflow_dispatch' || inputs.sign) && secrets.AZURE_CLIENT_SECRET || '' }}

steps:
- name: Checkout repository
uses: actions/checkout@v4

# GitHub-hosted windows-latest ships older/other toolchain versions than
# build.ps1 expects, so pin them explicitly with the setup-* actions
# (reliable on hosted runners, unlike on the self-hosted NetworkService
# account). build.ps1 then installs its own npm/pip deps and locates NSIS
# from electron-builder's cache.
- name: Setup Node.js 22
uses: actions/setup-node@v4
with:
node-version: "22"

- name: Setup .NET 9 SDK
uses: actions/setup-dotnet@v4
with:
dotnet-version: "9.0.x"

- name: Setup Python 3.12
uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Verify toolchain
run: |
$ErrorActionPreference = 'Stop'
$node = (& node --version).TrimStart('v')
if ([version]$node -lt [version]'22.0') { throw "Node $node < 22" }
Write-Host "node $node"
$dotnet = & dotnet --version
if ([version]($dotnet -split '-')[0] -lt [version]'9.0') { throw "dotnet $dotnet < 9.0" }
Write-Host "dotnet $dotnet"
Write-Host "python $(& python --version)"

- name: Install Python build dependencies
run: |
python -m pip install --upgrade pip
python -m pip install -r requirements.txt

# build.ps1 Step 7 needs makensis. electron-builder populates its NSIS
# cache during Step 3, so it is normally present by Step 7. Pre-install
# a system copy as a fallback so a cold runner never fails the step.
- name: Ensure NSIS is available
run: |
$ErrorActionPreference = 'SilentlyContinue'
$have = Get-Command makensis -ErrorAction SilentlyContinue
$sys = Test-Path "${env:ProgramFiles(x86)}\NSIS\makensis.exe"
if (-not $have -and -not $sys) {
Write-Host "Installing NSIS via choco..."
choco install nsis -y --no-progress
} else {
Write-Host "NSIS already present."
}

- name: Run build.ps1 (builds + signs MicroClawSetup.exe)
run: .\build.ps1

- name: Verify build artifacts
run: |
$ErrorActionPreference = 'Stop'
$expected = @(
'dist\microclaw-portable.zip',
'dist\MicroClawInstaller.zip',
'dist\MicroClawSetup.exe'
)
$missing = $expected | Where-Object { -not (Test-Path $_) }
if ($missing) {
Write-Host "::error::Missing expected artifacts: $($missing -join ', ')"
exit 1
}
Get-ChildItem dist | Format-Table Name, Length, LastWriteTime

# Confirm the single-exe installer is Authenticode-signed. When signing
# secrets are configured this must be Valid; otherwise we surface a clear
# warning (a tag build without secrets ships an unsigned exe that will
# still trip SmartScreen).
- name: Verify signature
run: |
$sig = Get-AuthenticodeSignature 'dist\MicroClawSetup.exe'
Write-Host "MicroClawSetup.exe signature status: $($sig.Status)"
if ($sig.SignerCertificate) {
Write-Host "Signer: $($sig.SignerCertificate.Subject)"
}
$configured = $env:TRUSTED_SIGNING_ENDPOINT -and $env:TRUSTED_SIGNING_ACCOUNT -and $env:TRUSTED_SIGNING_PROFILE
if ($configured) {
if ($sig.Status -ne 'Valid') {
Write-Host "::error::Trusted Signing was configured but the exe is not validly signed ($($sig.Status))."
exit 1
}
Write-Host "Signed installer verified."
} else {
Write-Host "::warning::MicroClawSetup.exe is UNSIGNED (signing toggle off or secrets not configured) and will trip SmartScreen. This is expected while the signing approach is under discussion."
}

- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: MicroClawSetup
path: dist\MicroClawSetup.exe
if-no-files-found: error

# Publish a GitHub Release with the signed exe when built from a tag.
- name: Publish GitHub Release
if: startsWith(github.ref, 'refs/tags/v')
uses: softprops/action-gh-release@v2
with:
files: dist\MicroClawSetup.exe
fail_on_unmatched_files: true
generate_release_notes: true
90 changes: 81 additions & 9 deletions build.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ if (-not $nodeFound) {
}

# -- Step 1: Build AppContainerLauncher.exe (.NET 9) --
Write-Host "`n=== Step 1/6: Build AppContainerLauncher ===" -ForegroundColor Cyan
Write-Host "`n=== Step 1/7: Build AppContainerLauncher ===" -ForegroundColor Cyan
$acProject = "$root\appcontainer"
if (-not (Test-Path "$acProject\AppContainerLauncher.csproj")) {
Write-Host " ERROR: appcontainer project not found at $acProject" -ForegroundColor Red
Expand Down Expand Up @@ -88,7 +88,7 @@ if (Test-Path $preloadSrc) {
}

# -- Step 2: Clean dist/ to prevent stale TypeScript output --
Write-Host "`n=== Step 2/6: Clean stale build artifacts ===" -ForegroundColor Cyan
Write-Host "`n=== Step 2/7: Clean stale build artifacts ===" -ForegroundColor Cyan
$distDir = "$root\desktop\dist"
if (Test-Path $distDir) {
Remove-Item "$distDir\*.js" -Force -ErrorAction SilentlyContinue
Expand All @@ -104,7 +104,7 @@ if (-not (Test-Path $outDist)) {
}

# -- Step 3: Build & pack desktop --
Write-Host "`n=== Step 3/6: Build & pack desktop ===" -ForegroundColor Cyan
Write-Host "`n=== Step 3/7: Build & pack desktop ===" -ForegroundColor Cyan
Push-Location "$root\desktop"
try {
# First-run bootstrap: install npm deps (including renderer via postinstall)
Expand Down Expand Up @@ -161,15 +161,15 @@ if (Test-Path $desktopAsar) {
}

# Step 4: Create portable zip
Write-Host "`n=== Step 4/6: Create portable zip ===" -ForegroundColor Cyan
Write-Host "`n=== Step 4/7: Create portable zip ===" -ForegroundColor Cyan
$zipPath = "$root\dist\microclaw-portable.zip"
if (Test-Path $zipPath) { Remove-Item $zipPath -Force }
Compress-Archive -Path "$root\desktop\release\win-unpacked\*" -DestinationPath $zipPath
$zipSizeMB = [math]::Round((Get-Item $zipPath).Length / 1MB, 1)
Write-Host " -> $zipPath ${zipSizeMB} MB"

# Step 5: Build installer (onedir mode to avoid WDAC blocking DLLs from temp)
Write-Host "`n=== Step 5/6: Build installer ===" -ForegroundColor Cyan
Write-Host "`n=== Step 5/7: Build installer ===" -ForegroundColor Cyan
Push-Location $root
$installerBuilt = $false

Expand Down Expand Up @@ -260,7 +260,7 @@ if (-not $installerBuilt) {
}

# Step 6: Pack onedir output into a single distributable zip
Write-Host "`n=== Step 6/6: Pack installer directory ===" -ForegroundColor Cyan
Write-Host "`n=== Step 6/7: Pack installer directory ===" -ForegroundColor Cyan
$installerDir = "$root\dist\MicroClawInstaller"
$installerZip = "$root\dist\MicroClawInstaller.zip"
if (-not (Test-Path $installerDir)) {
Expand All @@ -273,7 +273,79 @@ Compress-Archive -Path "$installerDir\*" -DestinationPath $installerZip
$instZipSizeMB = [math]::Round((Get-Item $installerZip).Length / 1MB, 1)
Write-Host " -> $installerZip ${instZipSizeMB} MB" -ForegroundColor Green

# Step 7: Build the single-exe setup (NSIS self-extractor) and code-sign it.
# This is the ONE file end users download. It extracts the onedir installer to a
# real directory under %LOCALAPPDATA% (not %TEMP%, preserving WDAC safety) and
# auto-launches MicroClawInstaller.exe. Only this stub needs signing to clear
# SmartScreen (it is the only file that carries Mark-of-the-Web on download).
Write-Host "`n=== Step 7/7: Build single-exe setup + sign ===" -ForegroundColor Cyan
$setupExe = "$root\dist\MicroClawSetup.exe"
$nsiScript = "$root\installer\microclaw-setup.nsi"
$setupIcon = "$root\deployer\assets\microclaw.ico"

# Resolve makensis: PATH first, then common install locations, then the copy
# that ships inside electron-builder's cache (already present after Step 3).
$makensis = $null
$mkCmd = Get-Command makensis -ErrorAction SilentlyContinue
if ($mkCmd) { $makensis = $mkCmd.Source }
if (-not $makensis) {
$mkCandidates = @(
"${env:ProgramFiles(x86)}\NSIS\makensis.exe",
"$env:ProgramFiles\NSIS\makensis.exe"
)
$mkCandidates += Get-ChildItem "$env:LOCALAPPDATA\electron-builder\Cache\nsis" -Recurse -Filter makensis.exe -ErrorAction SilentlyContinue |
Select-Object -ExpandProperty FullName
$makensis = $mkCandidates | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1
}

if (-not $makensis) {
Write-Host " ERROR: makensis (NSIS) not found. Install NSIS 3.x (e.g. 'choco install nsis')." -ForegroundColor Red
Write-Host " The onedir installer + zip were still produced." -ForegroundColor Yellow
exit 1
}
Write-Host " Using makensis: $makensis"

# Derive a 4-part version (NSIS VIProductVersion requires X.X.X.X).
$pkgVersion = '0.0.0'
try {
$pkgVersion = (Get-Content "$root\desktop\package.json" -Raw | ConvertFrom-Json).version
} catch { }
$verParts = @($pkgVersion -split '\.') + @('0','0','0','0')
$version4 = ($verParts[0..3]) -join '.'

if (Test-Path $setupExe) { Remove-Item $setupExe -Force }

$prev = $ErrorActionPreference
$ErrorActionPreference = "Continue"
& $makensis `
"/DPAYLOAD_DIR=$installerDir" `
"/DOUT_FILE=$setupExe" `
"/DICON=$setupIcon" `
"/DVERSION=$version4" `
$nsiScript 2>&1 | ForEach-Object { Write-Host " $_" }
$nsisExit = $LASTEXITCODE
$ErrorActionPreference = $prev
if ($nsisExit -ne 0 -or -not (Test-Path $setupExe)) {
Write-Host " ERROR: makensis failed (exit $nsisExit) — MicroClawSetup.exe not produced." -ForegroundColor Red
exit 1
}
$setupSizeMB = [math]::Round((Get-Item $setupExe).Length / 1MB, 1)
Write-Host " -> $setupExe ${setupSizeMB} MB" -ForegroundColor Green

# Code-sign the setup exe. This is a NO-OP unless Trusted Signing is configured
# (TRUSTED_SIGNING_ENDPOINT/ACCOUNT/PROFILE), so local + PR builds are unchanged.
# Run in a child process using the SAME PowerShell host (the signer calls
# `exit`, which would otherwise terminate this build script).
$psHost = (Get-Process -Id $PID).Path
if (-not $psHost) { $psHost = 'powershell' }
& $psHost -NoProfile -File "$root\scripts\windows\sign-artifact.ps1" -Path $setupExe
if ($LASTEXITCODE -ne 0) {
Write-Host " ERROR: signing step failed (exit $LASTEXITCODE)." -ForegroundColor Red
exit 1
}

Write-Host "`n=== Done ===" -ForegroundColor Green
Write-Host " Installer dir: $root\dist\MicroClawInstaller\"
Write-Host " Installer zip: $installerZip"
Write-Host " Portable: $zipPath"
Write-Host " Setup (one-exe): $setupExe"
Write-Host " Installer dir: $root\dist\MicroClawInstaller\"
Write-Host " Installer zip: $installerZip"
Write-Host " Portable: $zipPath"
80 changes: 80 additions & 0 deletions installer/microclaw-setup.nsi
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
; MicroClaw single-exe setup stub (NSIS)
; ---------------------------------------------------------------------------
; Wraps the PyInstaller *onedir* installer (dist\MicroClawInstaller\) into ONE
; downloadable, code-signable .exe. On launch it:
; 1. clears any stale staging from a previous run,
; 2. extracts the onedir payload to a REAL directory under %LOCALAPPDATA%
; (deliberately NOT %TEMP% — this preserves the WDAC-safe posture that
; the team chose onedir for; unsigned bundled DLLs never load from %TEMP%),
; 3. auto-launches MicroClawInstaller.exe (the existing web installer UI),
; 4. exits, leaving the installer running — no manual step required.
;
; Only THIS stub needs an Authenticode signature to clear SmartScreen: it is
; the single file the user downloads (and therefore the only one that carries
; Mark-of-the-Web). Files it extracts are created by a trusted local process,
; so they carry no MOTW and are not re-evaluated by SmartScreen.
;
; Build-time defines (passed by build.ps1):
; PAYLOAD_DIR absolute path to dist\MicroClawInstaller (the onedir)
; OUT_FILE absolute path of the .exe to produce (dist\MicroClawSetup.exe)
; ICON absolute path to the app .ico
; VERSION 4-part version string, e.g. 1.0.0.0
; ---------------------------------------------------------------------------

Unicode true

!ifndef PAYLOAD_DIR
!error "PAYLOAD_DIR is required (path to the onedir installer)."
!endif
!ifndef OUT_FILE
!define OUT_FILE "MicroClawSetup.exe"
!endif
!ifndef VERSION
!define VERSION "0.0.0.0"
!endif

Name "MicroClaw Setup"
OutFile "${OUT_FILE}"
!ifdef ICON
Icon "${ICON}"
!endif

; Extraction only — no admin needed. The inner installer self-elevates the
; individual steps (e.g. the Node.js MSI) when required.
RequestExecutionLevel user
SetCompressor /SOLID lzma

InstallDir "$LOCALAPPDATA\MicroClaw\Setup"
AutoCloseWindow true
ShowInstDetails show
BrandingText "MicroClaw"

; Version resource — a proper version block improves the signed file's
; presentation in SmartScreen / UAC / file properties.
VIProductVersion "${VERSION}"
VIAddVersionKey "ProductName" "MicroClaw Setup"
VIAddVersionKey "FileDescription" "MicroClaw Installer"
VIAddVersionKey "FileVersion" "${VERSION}"
VIAddVersionKey "ProductVersion" "${VERSION}"
VIAddVersionKey "CompanyName" "MicroClaw"
VIAddVersionKey "LegalCopyright" "Copyright (C) 2026 MicroClaw"

Page instfiles

Section "Install"
; Bound disk usage to a single copy: wipe any leftover staging first.
RMDir /r "$INSTDIR"
SetOutPath "$INSTDIR"
SetOverwrite on

DetailPrint "Extracting MicroClaw installer..."
File /r "${PAYLOAD_DIR}\*"

IfFileExists "$INSTDIR\MicroClawInstaller.exe" +3 0
MessageBox MB_ICONSTOP "Setup payload is incomplete: MicroClawInstaller.exe was not found."
Abort "Missing MicroClawInstaller.exe"

; Fire-and-forget: launch the real installer, then let this stub close.
DetailPrint "Starting MicroClaw installer..."
Exec '"$INSTDIR\MicroClawInstaller.exe"'
SectionEnd
Loading
Loading