diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 514d621..16ed4b5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -55,13 +55,16 @@ jobs: if ($fail) { exit 1 } Write-Host "All JS syntax OK ($($files.Count) files)" - - name: Build EXE (electron-builder --dir) + - name: Build EXE (electron-builder --dir, x64 + arm64) run: npm run pack - name: Verify packaged layout shell: pwsh run: | - $root = "dist/win-unpacked" + $targets = @{ + 'x64' = 'dist/win-unpacked' + 'arm64' = 'dist/win-arm64-unpacked' + } $required = @( "M2_SCOUT.exe", "TOOLS/rg.exe", "TOOLS/fd.exe", "TOOLS/cscope.exe", @@ -71,18 +74,25 @@ jobs: "resources/app.asar" ) $fail = $false - foreach ($r in $required) { - if (-not (Test-Path (Join-Path $root $r))) { Write-Host "MISSING: $r"; $fail = $true } - } - if (Test-Path (Join-Path $root "TOOLS/ZIP")) { - Write-Host "UNEXPECTED: TOOLS/ZIP was bundled (it should be excluded)"; $fail = $true + foreach ($arch in $targets.Keys) { + $root = $targets[$arch] + if (-not (Test-Path $root)) { Write-Host "MISSING build dir for ${arch}: $root"; $fail = $true; continue } + foreach ($r in $required) { + if (-not (Test-Path (Join-Path $root $r))) { Write-Host "[$arch] MISSING: $r"; $fail = $true } + } + if (Test-Path (Join-Path $root "TOOLS/ZIP")) { + Write-Host "[$arch] UNEXPECTED: TOOLS/ZIP was bundled (it should be excluded)"; $fail = $true + } + Write-Host "[$arch] checked $root" } if ($fail) { exit 1 } - Write-Host "Layout OK: M2_SCOUT.exe + TOOLS + FONTS + LOGO + INI templates present" + Write-Host "Layout OK for x64 + arm64: M2_SCOUT.exe + native TOOLS + FONTS + LOGO + INI templates present" - - name: Upload unpacked build + - name: Upload unpacked builds uses: actions/upload-artifact@v4 with: name: M2_SCOUT-win-unpacked - path: dist/win-unpacked + path: | + dist/win-unpacked + dist/win-arm64-unpacked retention-days: 14 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f437370..7c47d5b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,8 +3,8 @@ name: Release # Publish a Windows release when a version tag is pushed, e.g.: # git tag v0.0.1 # git push origin v0.0.1 -# Builds the portable zip with electron-builder and attaches it to a GitHub -# Release created for the tag. +# Builds the x64 and arm64 NSIS installers with electron-builder and attaches +# both to a GitHub Release created for the tag. on: push: @@ -45,7 +45,7 @@ jobs: - name: Install dependencies run: npm ci - - name: Build Windows installer (electron-builder NSIS) + - name: Build Windows installers (x64 + arm64, electron-builder NSIS) run: npm run dist - name: List artifacts @@ -57,9 +57,10 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - $setup = Get-ChildItem dist -Filter '*Setup*.exe' | Select-Object -First 1 - if (-not $setup) { Write-Host "No Setup .exe found in dist/"; exit 1 } + $setups = Get-ChildItem dist -Filter '*Setup*.exe' + if (-not $setups) { Write-Host "No Setup .exe found in dist/"; exit 1 } $tag = $env:GITHUB_REF_NAME - $notes = "Automated Windows x64 installer for M2_SCOUT $tag.`n`nRun the Setup .exe and follow the interactive installer. It installs M2_SCOUT, sets the app icon, and adds the Explorer right-click 'search this folder' menu pointing at the installed app (any previous entry is replaced)." - gh release create $tag $setup.FullName --title "M2_SCOUT $tag" --notes $notes + $notes = "Automated Windows installers for M2_SCOUT $tag.`n`nPick the installer that matches your PC:`n- ...-x64.exe : Intel / AMD 64-bit (most Windows PCs)`n- ...-arm64.exe : Windows on ARM (Snapdragon / ARM64 PCs)`n`nRun the Setup .exe and follow the interactive installer. It installs M2_SCOUT, sets the app icon, and adds the Explorer right-click 'search this folder' menu pointing at the installed app (any previous entry is replaced)." + $files = $setups | ForEach-Object { $_.FullName } + gh release create $tag $files --title "M2_SCOUT $tag" --notes $notes diff --git a/package.json b/package.json index 5a5990e..50f1612 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,9 @@ "start": "electron .", "lint": "node -e \"console.log('no linter configured')\"", "test": "node --test test/selftest.test.js", - "pack": "electron-builder --dir", - "dist": "electron-builder --win nsis --publish never" + "prepare-tools": "node scripts/prepare-tools.js", + "pack": "node scripts/prepare-tools.js && electron-builder --dir --x64 --arm64", + "dist": "node scripts/prepare-tools.js && electron-builder --win nsis --x64 --publish never && electron-builder --win nsis --arm64 --publish never" }, "keywords": [ "ripgrep", @@ -40,13 +41,8 @@ ], "extraFiles": [ { - "from": "TOOLS", - "to": "TOOLS", - "filter": [ - "**/*", - "!ZIP", - "!ZIP/**" - ] + "from": "build/tools/${arch}", + "to": "TOOLS" }, { "from": "FONTS", @@ -81,7 +77,7 @@ "installerIcon": "LOGO/M2_SCOUT.ico", "uninstallerIcon": "LOGO/M2_SCOUT.ico", "installerHeaderIcon": "LOGO/M2_SCOUT.ico", - "artifactName": "${productName}-Setup-${version}.${ext}", + "artifactName": "${productName}-Setup-${version}-${arch}.${ext}", "uninstallDisplayName": "${productName} ${version}" } } diff --git a/scripts/prepare-tools.js b/scripts/prepare-tools.js new file mode 100644 index 0000000..f71f675 --- /dev/null +++ b/scripts/prepare-tools.js @@ -0,0 +1,152 @@ +/* + * M2_SCOUT + * Copyright (c) 2026 OA Hsiao + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT License found in the + * LICENSE file in the root directory of this source tree. + */ + +// ============================================================ +// M2_SCOUT - stage per-architecture native search tools. +// +// electron-builder ships one app per CPU target (x64 + arm64). The bundled +// search tools (rg.exe / fd.exe / cscope.exe) are NATIVE binaries, so each +// installer must carry the matching architecture. This script extracts the +// correct Windows binaries out of TOOLS/ZIP into build/tools//, and +// package.json wires those folders into each pack via: +// +// extraFiles: [{ from: "build/tools/${arch}", to: "TOOLS" }] +// +// Mapping (electron-builder ${arch} -> ripgrep/fd asset platform token): +// x64 -> x86_64 (native) +// arm64 -> aarch64 (native) +// +// cscope has no aarch64 Windows build, so the x64 cscope.exe is reused for +// arm64 (it runs under the Windows on ARM x64 emulation layer). +// +// The script is idempotent: it wipes build/tools/ and rebuilds it. It only +// uses Node core + PowerShell's Expand-Archive (the build host is Windows). +// ============================================================ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const repoRoot = path.resolve(__dirname, '..'); +const zipDir = path.join(repoRoot, 'TOOLS', 'ZIP'); +const outRoot = path.join(repoRoot, 'build', 'tools'); + +// electron-builder ${arch} value -> ripgrep/fd asset platform token. +const ARCHES = { + x64: 'x86_64', + arm64: 'aarch64', +}; + +function log(msg) { console.log(`==> ${msg}`); } +function fail(msg) { console.error(`ERR ${msg}`); process.exit(1); } + +function listZips() { + try { + return fs.readdirSync(zipDir).filter((f) => f.toLowerCase().endsWith('.zip')); + } catch (_e) { + return []; + } +} + +// Compare dotted version strings ("a.b.c"). Returns 1 / 0 / -1. +function verCmp(a, b) { + const pa = String(a).split('.').map((n) => parseInt(n, 10) || 0); + const pb = String(b).split('.').map((n) => parseInt(n, 10) || 0); + for (let i = 0; i < Math.max(pa.length, pb.length); i += 1) { + const d = (pa[i] || 0) - (pb[i] || 0); + if (d !== 0) return d > 0 ? 1 : -1; + } + return 0; +} + +// From the zip list, pick the highest-version name matching `re` (group 1 = version). +function pickHighest(zips, re) { + let best = null; + for (const name of zips) { + const m = name.match(re); + if (!m) continue; + const ver = m[1] || '0'; + if (!best || verCmp(ver, best.ver) > 0) best = { name, ver }; + } + return best ? best.name : null; +} + +// Recursively find the first file whose basename matches `name` (case-insensitive). +function findFile(root, name) { + const target = name.toLowerCase(); + const stack = [root]; + while (stack.length) { + const dir = stack.pop(); + let entries = []; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (_e) { entries = []; } + for (const e of entries) { + const full = path.join(dir, e.name); + if (e.isDirectory()) stack.push(full); + else if (e.name.toLowerCase() === target) return full; + } + } + return null; +} + +// Extract one exe out of a zip in TOOLS/ZIP into destDir. +function extract(zipName, exeName, destDir) { + const zipPath = path.join(zipDir, zipName); + const tmp = path.join(outRoot, `_tmp_${Date.now()}_${Math.random().toString(36).slice(2)}`); + fs.mkdirSync(tmp, { recursive: true }); + try { + const cmd = `Expand-Archive -LiteralPath '${zipPath.replace(/'/g, "''")}' ` + + `-DestinationPath '${tmp.replace(/'/g, "''")}' -Force`; + const r = spawnSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', cmd], { + encoding: 'utf8', timeout: 120000, windowsHide: true, + }); + if (r.status !== 0) fail(`Expand-Archive failed for ${zipName}: ${(r.stderr || '').trim() || r.status}`); + const src = findFile(tmp, exeName); + if (!src) fail(`${exeName} not found inside ${zipName}`); + fs.mkdirSync(destDir, { recursive: true }); + fs.copyFileSync(src, path.join(destDir, exeName)); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +} + +function main() { + const zips = listZips(); + if (!zips.length) fail(`No tool archives found in ${zipDir}`); + + // cscope ships a single (x64) build shared by every architecture. + const csZip = pickHighest(zips, /^cscope-(\d+(?:\.\d+)*)\.zip$/i) + || zips.find((z) => /^cscope.*\.zip$/i.test(z)); + if (!csZip) fail('No cscope archive in TOOLS/ZIP'); + + // Start from a clean staging tree (never touches build/installer.nsh). + fs.rmSync(outRoot, { recursive: true, force: true }); + + for (const [arch, plat] of Object.entries(ARCHES)) { + const destDir = path.join(outRoot, arch); + log(`Staging ${arch} tools (${plat})`); + + const rgZip = pickHighest(zips, new RegExp(`^ripgrep-(\\d+\\.\\d+\\.\\d+)-${plat}-pc-windows-msvc\\.zip$`, 'i')); + const fdZip = pickHighest(zips, new RegExp(`^fd-v?(\\d+\\.\\d+\\.\\d+)-${plat}-pc-windows-msvc\\.zip$`, 'i')); + if (!rgZip) fail(`No ripgrep ${plat} archive in TOOLS/ZIP`); + if (!fdZip) fail(`No fd ${plat} archive in TOOLS/ZIP`); + + extract(rgZip, 'rg.exe', destDir); + extract(fdZip, 'fd.exe', destDir); + extract(csZip, 'cscope.exe', destDir); + log(` rg.exe <- ${rgZip}`); + log(` fd.exe <- ${fdZip}`); + log(` cscope.exe <- ${csZip}`); + } + + log(`Tool staging complete -> ${path.relative(repoRoot, outRoot)}`); +} + +main(); diff --git a/scripts/release.ps1 b/scripts/release.ps1 index a026ce9..cd89166 100644 --- a/scripts/release.ps1 +++ b/scripts/release.ps1 @@ -111,8 +111,8 @@ if (-not $Publish) { Write-Step "Verification build (electron-builder NSIS) for v$target" npm run dist if ($LASTEXITCODE -ne 0) { Fail 'Build failed' } - $setup = Get-ChildItem (Join-Path $repoRoot 'dist') -Filter '*Setup*.exe' -ErrorAction SilentlyContinue | Select-Object -First 1 - if ($setup) { Write-Ok "Installer built: $($setup.Name)" } else { Fail 'No Setup .exe produced' } + $setups = Get-ChildItem (Join-Path $repoRoot 'dist') -Filter '*Setup*.exe' -ErrorAction SilentlyContinue + if ($setups) { foreach ($s in $setups) { Write-Ok "Installer built: $($s.Name)" } } else { Fail 'No Setup .exe produced' } Write-Host '' Write-Host 'Verify-only run complete. Nothing was committed, tagged, or published.' -ForegroundColor Yellow Write-Host "To publish: pwsh scripts/release.ps1 -Version $target -Publish" -ForegroundColor Yellow