Skip to content
Merged
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
30 changes: 20 additions & 10 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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
15 changes: 8 additions & 7 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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

16 changes: 6 additions & 10 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -40,13 +41,8 @@
],
"extraFiles": [
{
"from": "TOOLS",
"to": "TOOLS",
"filter": [
"**/*",
"!ZIP",
"!ZIP/**"
]
"from": "build/tools/${arch}",
"to": "TOOLS"
},
{
"from": "FONTS",
Expand Down Expand Up @@ -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}"
}
}
Expand Down
152 changes: 152 additions & 0 deletions scripts/prepare-tools.js
Original file line number Diff line number Diff line change
@@ -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/<arch>/, 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();
4 changes: 2 additions & 2 deletions scripts/release.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading