From e52dd458d83c8b8a8a6c30a74bd36bfd2809d8dd Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:11:41 +0200 Subject: [PATCH] ci(release): build and publish the installer from CI Push to main runs the gates, requires a new package.json version, builds the NSIS installer and publishes v with generated notes plus a stable-named Filesmith-Setup-x64.exe. PRs touching the release machinery and manual dispatches run a dry run that uploads the installer instead. fetch-binaries --pinned stages every bundled tool from a pinned, SHA-256-checked download of the version the local build ships; verify-bundle fails the build if a tool is missing or does not run, before and after packing. Local builds are unchanged. Closes #26 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 161 ++++++++++++++++++++++ CLAUDE.md | 4 + package-lock.json | 4 +- package.json | 2 +- scripts/fetch-binaries.mjs | 74 +++++++--- scripts/pinned-tools.mjs | 250 ++++++++++++++++++++++++++++++++++ scripts/verify-bundle.mjs | 150 ++++++++++++++++++++ 7 files changed, 623 insertions(+), 22 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 scripts/pinned-tools.mjs create mode 100644 scripts/verify-bundle.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..e490ad4 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,161 @@ +# Every push to main ships: build the NSIS installer and publish it as a GitHub +# Release named after package.json's version, with generated notes from the +# merged PRs. Existing versions are immutable: a release requires a NEW version +# in package.json (bump it inside the PR), and CI refuses to overwrite one. +# +# The bundled tools (ImageMagick, CaesiumCLT, 7-Zip, ffmpeg, mutool, +# LibreOffice, Ghostscript, Real-ESRGAN) are not in git, and a runner has none +# of the local installs fetch-binaries copies from. `--pinned` stages the same +# versions from official downloads, each checked against a pinned SHA-256 +# (scripts/pinned-tools.mjs), and verify-bundle fails the build if any tool is +# missing or does not run, both before and after electron-builder packs it. +# +# Pull requests that touch the release machinery, and manual dispatches, run a +# DRY RUN: everything up to and including the verified installer, uploaded as a +# workflow artifact, but never published. +# +# Gates: typecheck, lint, prettier, unit tests. The e2e suite stays the local +# pre-PR gate (it launches the built app with real tools, CLAUDE.md). +# +# Unsigned: no certificate is configured. +name: release + +on: + push: + branches: [main] + # Docs and licence text cannot change the installer. Without this a docs + # push would fail on "Require a new version", or, bumped, publish an + # identical installer under a new name for nothing. + paths-ignore: + - '**.md' + - 'docs/**' + - 'LICENSE' + - '.github/ISSUE_TEMPLATE/**' + pull_request: + paths: + - '.github/workflows/release.yml' + - 'scripts/fetch-binaries.mjs' + - 'scripts/pinned-tools.mjs' + - 'scripts/verify-bundle.mjs' + - 'electron-builder.yml' + - 'package.json' + workflow_dispatch: + +concurrency: + # Releases from main queue rather than race; a PR's newer push replaces its + # older dry run. + group: release-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + release: + runs-on: windows-latest + timeout-minutes: 75 + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - run: npm ci + - run: npm run typecheck + - run: npm run lint + - run: npx prettier --check . + - run: npm test + + - name: Read version + id: ver + shell: pwsh + run: | + $v = (Get-Content package.json -Raw | ConvertFrom-Json).version + if (-not $v) { throw 'could not read version from package.json' } + "version=$v" >> $env:GITHUB_OUTPUT + + # Runs on dry runs too, so a PR that forgot the bump fails here, not + # after it is merged. + - name: Require a new version + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + run: | + # Actions runs pwsh with $ErrorActionPreference='Stop', and PowerShell + # 7.4 can turn a non-zero NATIVE exit into a throw. `gh release view` + # exits 1 when the tag does not exist, which is the expected path, so + # the exit code is checked by hand. + $ErrorActionPreference = 'Continue' + $PSNativeCommandUseErrorActionPreference = $false + $v = '${{ steps.ver.outputs.version }}' + gh release view "v$v" *> $null + if ($LASTEXITCODE -eq 0) { throw "Release v$v already exists. Bump package.json in the PR." } + Write-Host "v$v is new" + $global:LASTEXITCODE = 0 + + - name: Fetch pinned tools + run: node scripts/fetch-binaries.mjs --pinned + + - name: Verify bundled tools (resources/) + run: node scripts/verify-bundle.mjs resources --manifest dist/resources-manifest.txt + + # --publish never: on CI electron-builder tries to publish ITSELF and + # fails wanting a token; the Publish step below owns publishing. + - run: npm run build + - run: npx electron-builder --win --publish never + + # electron-builder only WARNS when an extraResources source is missing, so + # check what actually got packed. + - name: Verify bundled tools (packed app) + run: node scripts/verify-bundle.mjs dist/win-unpacked/resources --manifest dist/packed-manifest.txt + + - name: Check installer + id: exe + shell: pwsh + run: | + $v = '${{ steps.ver.outputs.version }}' + $exe = "dist/Filesmith-Setup-x64-$v.exe" + if (-not (Test-Path $exe)) { throw "installer not found: $exe" } + $item = Get-Item $exe + $sha = (Get-FileHash $exe -Algorithm SHA256).Hash + $mb = [math]::Round($item.Length / 1MB, 1) + "path=$exe" >> $env:GITHUB_OUTPUT + "## Filesmith $v installer" >> $env:GITHUB_STEP_SUMMARY + "" >> $env:GITHUB_STEP_SUMMARY + "- ``$($item.Name)``: $mb MB ($($item.Length) bytes)" >> $env:GITHUB_STEP_SUMMARY + "- sha256 ``$sha``" >> $env:GITHUB_STEP_SUMMARY + "- unsigned (no certificate configured)" >> $env:GITHUB_STEP_SUMMARY + "- event ``${{ github.event_name }}``" >> $env:GITHUB_STEP_SUMMARY + + - name: Upload dry-run installer + if: github.event_name != 'push' + uses: actions/upload-artifact@v4 + with: + name: Filesmith-Setup-x64-${{ steps.ver.outputs.version }}-dryrun + path: | + ${{ steps.exe.outputs.path }} + dist/resources-manifest.txt + dist/packed-manifest.txt + retention-days: 7 + # The installer is already LZMA-compressed. + compression-level: 0 + + - name: Publish + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + run: | + $ErrorActionPreference = 'Continue' + $PSNativeCommandUseErrorActionPreference = $false + $v = '${{ steps.ver.outputs.version }}' + $exe = '${{ steps.exe.outputs.path }}' + # A stable-named copy of the same installer, so + # https://github.com//releases/latest/download/Filesmith-Setup-x64.exe + # always serves the newest release. The versioned name stays primary. + $stable = 'dist/Filesmith-Setup-x64.exe' + Copy-Item $exe $stable -Force + gh release create "v$v" $exe $stable --title "Filesmith $v" --generate-notes --latest + if ($LASTEXITCODE -ne 0) { throw 'Release publication failed' } + "- published ``v$v``" >> $env:GITHUB_STEP_SUMMARY diff --git a/CLAUDE.md b/CLAUDE.md index 811f537..be5207b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -80,6 +80,10 @@ resources/bin/ bundled CLI binaries (gitignored; fetched by scripts, packed by e - `npm run package` — electron-vite build + electron-builder NSIS installer to `dist/`. - `npm run test:e2e` — Playwright end-to-end (launches the built app via `_electron`; run `npm run build` first). Covers the preload/IPC/engine chain unit tests can't reach. +- Releases: push to main runs `.github/workflows/release.yml` (gates, then requires a NEW + `package.json` version, builds with `fetch-binaries --pinned`, publishes `v`). Bump the + version in the PR. Tool versions + SHA-256 live in `scripts/pinned-tools.mjs`; PRs touching + the release machinery get a dry-run installer artifact. ## Conventions diff --git a/package-lock.json b/package-lock.json index 14fd14d..6cc01ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "filesmith", - "version": "0.5.0", + "version": "0.5.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "filesmith", - "version": "0.5.0", + "version": "0.5.1", "hasInstallScript": true, "license": "MIT", "devDependencies": { diff --git a/package.json b/package.json index 5acc24f..5032352 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "filesmith", - "version": "0.5.0", + "version": "0.5.1", "description": "A desktop file toolkit: convert, compress, resize, upscale, remove backgrounds, and PDF tools.", "author": "Max", "license": "MIT", diff --git a/scripts/fetch-binaries.mjs b/scripts/fetch-binaries.mjs index 80cebd9..680f47f 100644 --- a/scripts/fetch-binaries.mjs +++ b/scripts/fetch-binaries.mjs @@ -26,6 +26,10 @@ * Install the copy-sourced tools first if missing: * winget install ImageMagick.ImageMagick SaeraSoft.CaesiumCLT ArtifexSoftware.mutool * (LibreOffice: install from libreoffice.org) + * + * `--pinned` (the CI release build, .github/workflows/release.yml) replaces every + * local source above with a pinned, SHA-256-checked download of the same + * version, staged by scripts/pinned-tools.mjs. Without the flag nothing changes. */ import { existsSync, @@ -41,6 +45,7 @@ import { execFileSync } from 'node:child_process' import { join, dirname } from 'node:path' import { fileURLToPath } from 'node:url' import { tmpdir } from 'node:os' +import { stagePinnedTools } from './pinned-tools.mjs' const HERE = dirname(fileURLToPath(import.meta.url)) const ROOT = join(HERE, '..') @@ -51,6 +56,12 @@ mkdirSync(BIN, { recursive: true }) const log = (...a) => console.log(...a) const mb = (p) => (statSync(p).size / MB).toFixed(1) +/** `--pinned`: the staged tool sources (see pinned-tools.mjs); null = local sources. */ +const PINNED_MODE = process.argv.includes('--pinned') +const PINNED_STAGE = join(tmpdir(), 'filesmith-pinned') +/** @type {import('./pinned-tools.mjs').StagedTools | null} */ +let PIN = null + /** Locate an executable on PATH via `where`; null if absent. */ function which(name) { try { @@ -66,7 +77,7 @@ function which(name) { * produces an installer where every image operation fails with "no decode * delegate" on any machine that has no system ImageMagick to fall back to. */ function bundleImageMagick() { - const magick = which('magick') + const magick = PIN ? join(PIN.magickDir, 'magick.exe') : which('magick') if (!magick) { log(' ! ImageMagick not found — skip (winget install ImageMagick.ImageMagick)') return @@ -97,7 +108,7 @@ function bundleImageMagick() { /** CaesiumCLT: a single self-contained exe. */ function bundleCaesium() { - const c = which('caesiumclt') + const c = PIN ? PIN.caesiumExe : which('caesiumclt') if (!c) { log(' ! CaesiumCLT not found — skip (winget install SaeraSoft.CaesiumCLT)') return @@ -117,7 +128,7 @@ function bundleSevenZip() { join('C:', 'Program Files', '7-Zip'), join('C:', 'Program Files (x86)', '7-Zip') ].filter(Boolean) - const dir = dirs.find((d) => existsSync(join(d, '7z.exe'))) + const dir = PIN ? PIN.sevenZipDir : dirs.find((d) => existsSync(join(d, '7z.exe'))) if (!dir) { log(' ! 7-Zip not found — skip (winget install 7zip.7zip)') return @@ -133,6 +144,12 @@ function bundleSevenZip() { /** ffmpeg: download the smaller "essentials" static build and extract ffmpeg.exe. */ async function bundleFfmpeg() { + if (PIN) { + for (const f of ['ffmpeg.exe', 'ffprobe.exe']) + copyFileSync(join(PIN.ffmpegBinDir, f), join(BIN, f)) + log(` ✓ ffmpeg: ffmpeg.exe + ffprobe.exe (${mb(join(BIN, 'ffmpeg.exe'))} MB, pinned)`) + return + } const url = 'https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip' const tmp = join(tmpdir(), 'filesmith-ffmpeg') rmSync(tmp, { recursive: true, force: true }) @@ -184,7 +201,7 @@ async function bundleFfmpeg() { /** mutool (MuPDF): a single static exe for the PDF tools. */ function bundleMutool() { - const m = which('mutool') + const m = PIN ? PIN.mutoolExe : which('mutool') if (!m) { log(' ! mutool not found — skip (winget install ArtifexSoftware.mutool)') return @@ -213,7 +230,9 @@ function bundleLibreOffice() { 'C:\\Program Files (x86)\\LibreOffice', process.env.LIBREOFFICE_DIR || '' ].filter(Boolean) - const src = candidates.find((r) => existsSync(join(r, 'program', 'soffice.exe'))) + const src = PIN + ? PIN.libreOfficeDir + : candidates.find((r) => existsSync(join(r, 'program', 'soffice.exe'))) if (!src) { log(' ! LibreOffice not found — skip. To bundle it, install LibreOffice') log(' (winget install TheDocumentFoundation.LibreOffice) or set LIBREOFFICE_DIR') @@ -246,6 +265,11 @@ async function bundleGhostscript() { for (const d of SUBSET) if (existsSync(join(root, d))) cpSync(join(root, d), join(dest, d), { recursive: true }) } + if (PIN) { + copySubset(PIN.ghostscriptDir) + log(' ✓ Ghostscript: copied from the pinned download') + return + } // (a) local install: C:\Program Files\gs\gs\ or $GHOSTSCRIPT_DIR for (const base of ['C:\\Program Files\\gs', process.env.GHOSTSCRIPT_DIR || ''].filter(Boolean)) { try { @@ -329,12 +353,14 @@ async function bundleRealesrgan() { cpSync(join(src, m + ext), join(dest, 'models', m + ext)) } // (a) a local copy (RCMM installs the same binary on demand) or $REALESRGAN_DIR - const local = [ - join(process.env.LOCALAPPDATA || '', 'RCMM', 'tools', 'realesrgan'), - process.env.REALESRGAN_DIR || '' - ] - .filter(Boolean) - .find((d) => existsSync(join(d, EXE))) + const local = PIN + ? PIN.realesrganDir + : [ + join(process.env.LOCALAPPDATA || '', 'RCMM', 'tools', 'realesrgan'), + process.env.REALESRGAN_DIR || '' + ] + .filter(Boolean) + .find((d) => existsSync(join(d, EXE))) if (local) { copySubset(local) log(` ✓ Real-ESRGAN: copied from ${local}`) @@ -506,15 +532,25 @@ if (process.argv.includes('--lo-only')) { } else if (process.argv.includes('--esrgan-only')) { await bundleRealesrgan() } else { + if (PINNED_MODE) + PIN = await stagePinnedTools(PINNED_STAGE, { + allowInstall: process.env.CI === 'true' || process.argv.includes('--pinned-allow-install') + }) log('Populating resources/bin …') - bundleImageMagick() - bundleCaesium() - bundleSevenZip() - await bundleFfmpeg() - bundleMutool() - if (!SKIPPED.has('ghostscript')) await bundleGhostscript() - if (!SKIPPED.has('realesrgan')) await bundleRealesrgan() - if (!SKIPPED.has('libreoffice')) bundleLibreOffice() + try { + bundleImageMagick() + bundleCaesium() + bundleSevenZip() + await bundleFfmpeg() + bundleMutool() + if (!SKIPPED.has('ghostscript')) await bundleGhostscript() + if (!SKIPPED.has('realesrgan')) await bundleRealesrgan() + if (!SKIPPED.has('libreoffice')) bundleLibreOffice() + } finally { + // The staged ImageMagick install leaves registry paths behind; deleting the + // stage makes sure they cannot mask a bundle missing its coder modules. + if (PIN) rmSync(PINNED_STAGE, { recursive: true, force: true }) + } const bundled = readdirSync(BIN).filter((f) => f !== '.gitkeep') const total = bundled.reduce((s, f) => s + statSync(join(BIN, f)).size, 0) diff --git a/scripts/pinned-tools.mjs b/scripts/pinned-tools.mjs new file mode 100644 index 0000000..1c7ceae --- /dev/null +++ b/scripts/pinned-tools.mjs @@ -0,0 +1,250 @@ +// @ts-check +/** + * Pinned, SHA-256-checked sources for every bundled tool, used by + * `fetch-binaries.mjs --pinned` (the CI release build). A clean runner has + * none of the local installs fetch-binaries normally copies from, so this + * stages the SAME versions the local installer ships (checked 2026-10-04 + * against resources/ of the v0.5.0 build) from their official downloads. + * + * Hashes: GitHub release asset digests and the winget-pkgs manifests for the + * same versions agree on every entry; mupdf and Real-ESRGAN were downloaded + * and their extracted exes are byte-identical to the local bundle. + * + * Two tools cannot be staged by extraction alone and are INSTALLED instead, + * so `--pinned` refuses to run outside CI unless `--pinned-allow-install`: + * - ImageMagick ships its dynamic (modules) build only as an Inno Setup exe; + * it is installed silently into the staging dir. + * - LibreOffice's MSI admin image (`msiexec /a`) does not run (corrupt + * bootstrap.ini, see bundleLibreOffice), so it gets a real per-dir install. + * + * To bump a tool: change version/url/sha256 here (the release page or the + * winget-pkgs manifest lists the digest), then let the PR dry run verify it. + */ +import { createHash } from 'node:crypto' +import { + existsSync, + mkdirSync, + readFileSync, + readdirSync, + rmSync, + statSync, + writeFileSync +} from 'node:fs' +import { execFileSync } from 'node:child_process' +import { join } from 'node:path' + +export const PINNED = { + // Standalone 7z-only extractor: unpacks the 7-Zip installer (a 7z SFX), + // whose full 7z.exe then extracts everything else. + sevenZipR: { + version: '26.02', + url: 'https://github.com/ip7z/7zip/releases/download/26.02/7zr.exe', + sha256: '56b8cc9f4971cef253644fafe54063ed7fdca551d4dee0f8c6baa81b855acd72' + }, + sevenZip: { + version: '26.02', + url: 'https://github.com/ip7z/7zip/releases/download/26.02/7z2602-x64.exe', + sha256: '6745fa76dc2ea031596d8678f6f6b99c3c1b435b4164a63485adbbc7b8d82ef0' + }, + imagemagick: { + version: '7.1.2-29 Q16-HDRI x64 dll', + url: 'https://github.com/ImageMagick/ImageMagick/releases/download/7.1.2-29/ImageMagick-7.1.2-29-Q16-HDRI-x64-dll.exe', + sha256: '94c025d0f572b1f7db03c380002485b49069c6d02796f462ffd7052874ba7467' + }, + caesium: { + version: '1.4.0', + url: 'https://github.com/Lymphatus/caesium-clt/releases/download/v1.4.0/caesiumclt-v1.4.0-x86_64-pc-windows-msvc.zip', + sha256: 'a56454a83207fc25830f4d12679d7385c0906060f2ce5f54345ac5a4cf94b00f' + }, + // gyan.dev's essentials build, the same one the local download fetches, + // from its versioned GitHub mirror (gyan.dev's own URL always means "latest"). + ffmpeg: { + version: '9.0.2 essentials', + url: 'https://github.com/GyanD/codexffmpeg/releases/download/9.0.2/ffmpeg-9.0.2-essentials_build.7z', + sha256: '4705843ccaaf54257c16ad90f3e952ece33c17df964ecf7bfdbb0f49c7171077' + }, + mutool: { + version: '1.23.0', + url: 'https://mupdf.com/downloads/archive/mupdf-1.23.0-windows.zip', + sha256: '702386666f0d5a7d968102759843509c2549c1a206489add6cd284f657393ff8' + }, + libreoffice: { + version: '26.2.4.2', + url: 'https://downloadarchive.documentfoundation.org/libreoffice/old/26.2.4.2/win/x86_64/LibreOffice_26.2.4.2_Win_x86-64.msi', + sha256: '202f26cda071c5aa4996a5a28412fddceb3891dceb0366982c62650456c0730f' + }, + ghostscript: { + version: '10.07.1', + url: 'https://github.com/ArtifexSoftware/ghostpdl-downloads/releases/download/gs10071/gs10071w64.exe', + sha256: '3a4c28d0aac47aa7cccd35a5932c55110376e9dbd966898dde388b7faba444a4' + }, + realesrgan: { + version: 'v0.2.5.0 (ncnn-vulkan 20220424)', + url: 'https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.5.0/realesrgan-ncnn-vulkan-20220424-windows.zip', + sha256: 'abc02804e17982a3be33675e4d471e91ea374e65b70167abc09e31acb412802d' + } +} + +/** @typedef {keyof typeof PINNED} PinnedId */ + +/** + * @typedef {object} StagedTools + * @property {string} magickDir folder holding magick.exe, its DLLs and modules/ + * @property {string} caesiumExe + * @property {string} sevenZipDir folder holding 7z.exe, 7z.dll, License.txt + * @property {string} ffmpegBinDir folder holding ffmpeg.exe + ffprobe.exe + * @property {string} mutoolExe + * @property {string} libreOfficeDir install root (program/soffice.exe inside) + * @property {string} ghostscriptDir root with bin/ lib/ Resource/ iccprofiles/ + * @property {string} realesrganDir root with the exe, DLLs and models/ + */ + +const log = (...a) => console.log(...a) + +/** Download a pinned file and refuse it unless its SHA-256 matches. */ +async function download(/** @type {PinnedId} */ id, /** @type {string} */ dest) { + const { url, sha256, version } = PINNED[id] + log(` … ${id} ${version}: ${url}`) + const res = await fetch(url) + if (!res.ok) throw new Error(`${id}: HTTP ${res.status} for ${url}`) + const buf = Buffer.from(await res.arrayBuffer()) + const got = createHash('sha256').update(buf).digest('hex') + if (got !== sha256) + throw new Error( + `${id}: SHA-256 mismatch for ${url}\n expected ${sha256}\n got ${got}` + ) + writeFileSync(dest, buf) + log(` ✓ ${id}: ${(buf.length / 1024 / 1024).toFixed(1)} MB, sha256 ok`) + return dest +} + +/** The single top-level folder an archive extracted into (e.g. mupdf-1.23.0-windows). */ +function onlySubdir(/** @type {string} */ dir) { + const subs = readdirSync(dir).filter((f) => statSync(join(dir, f)).isDirectory()) + if (subs.length !== 1) throw new Error(`expected one folder in ${dir}, found: ${subs.join(', ')}`) + return join(dir, subs[0]) +} + +function mustExist(/** @type {string} */ p, /** @type {string} */ what) { + if (!existsSync(p)) throw new Error(`${what}: ${p} missing after staging`) + return p +} + +/** + * Download, verify and unpack every pinned tool into `stage`. Throws on the + * first failure: a release build with a missing or unverified tool must stop. + * @param {string} stage + * @param {{ allowInstall: boolean }} opts + * @returns {Promise} + */ +export async function stagePinnedTools(stage, { allowInstall }) { + if (!allowInstall) + throw new Error( + '--pinned installs ImageMagick and LibreOffice into a staging dir (registry entries\n' + + ' included). It runs on CI (CI=true); pass --pinned-allow-install to run it elsewhere.' + ) + rmSync(stage, { recursive: true, force: true }) + mkdirSync(stage, { recursive: true }) + const dl = join(stage, 'downloads') + mkdirSync(dl) + log(`Staging pinned tools in ${stage} …`) + + // 7-Zip first: its 7z.exe is the extractor for everything after it. + const sevenZipR = await download('sevenZipR', join(dl, '7zr.exe')) + const sevenZipDir = join(stage, '7zip') + execFileSync( + sevenZipR, + ['x', await download('sevenZip', join(dl, '7z.exe')), `-o${sevenZipDir}`, '-y'], + { + stdio: 'ignore' + } + ) + const sevenZip = mustExist(join(sevenZipDir, '7z.exe'), '7-Zip') + mustExist(join(sevenZipDir, '7z.dll'), '7-Zip') + const extract = (/** @type {string} */ archive, /** @type {string} */ out) => { + execFileSync(sevenZip, ['x', archive, `-o${out}`, '-y'], { stdio: 'ignore' }) + return out + } + + const caesiumDir = extract( + await download('caesium', join(dl, 'caesium.zip')), + join(stage, 'caesium') + ) + const caesiumExe = mustExist(join(onlySubdir(caesiumDir), 'caesiumclt.exe'), 'CaesiumCLT') + + const ffmpegDir = extract(await download('ffmpeg', join(dl, 'ffmpeg.7z')), join(stage, 'ffmpeg')) + const ffmpegBinDir = join(onlySubdir(ffmpegDir), 'bin') + mustExist(join(ffmpegBinDir, 'ffmpeg.exe'), 'ffmpeg') + mustExist(join(ffmpegBinDir, 'ffprobe.exe'), 'ffprobe') + + const mutoolDir = extract(await download('mutool', join(dl, 'mupdf.zip')), join(stage, 'mupdf')) + const mutoolExe = mustExist(join(onlySubdir(mutoolDir), 'mutool.exe'), 'mutool') + + // The Ghostscript installer is NSIS; 7-Zip unpacks it to the install layout. + const ghostscriptDir = extract( + await download('ghostscript', join(dl, 'gs.exe')), + join(stage, 'gs') + ) + mustExist(join(ghostscriptDir, 'bin', 'gswin64c.exe'), 'Ghostscript') + + const realesrganDir = extract( + await download('realesrgan', join(dl, 'realesrgan.zip')), + join(stage, 'realesrgan') + ) + mustExist(join(realesrganDir, 'realesrgan-ncnn-vulkan.exe'), 'Real-ESRGAN') + + // ImageMagick: silent Inno install into the stage. The staging dir is deleted + // after bundling, so the registry paths the installer leaves behind point at + // nothing and cannot mask a bundle with missing coder modules. + const magickDir = join(stage, 'imagemagick') + execFileSync( + await download('imagemagick', join(dl, 'imagemagick.exe')), + ['/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART', '/SP-', '/NOICONS', `/DIR=${magickDir}`], + { stdio: 'inherit' } + ) + mustExist(join(magickDir, 'magick.exe'), 'ImageMagick') + mustExist(join(magickDir, 'modules', 'coders'), 'ImageMagick coder modules') + + // LibreOffice: a real install (see header), confined to the stage. + const libreOfficeDir = join(stage, 'libreoffice') + const msi = await download('libreoffice', join(dl, 'libreoffice.msi')) + const msiLog = join(stage, 'libreoffice-msi.log') + log(' … installing LibreOffice (msiexec, a few minutes) …') + try { + execFileSync( + 'msiexec', + [ + '/i', + msi, + '/qn', + '/norestart', + `INSTALLLOCATION=${libreOfficeDir}`, + 'REBOOTYESNO=No', + '/l*', + msiLog + ], + { stdio: 'inherit' } + ) + } catch (e) { + // 3010 = installed, reboot requested: irrelevant for a copy source. + const status = /** @type {{ status?: number }} */ (e).status + if (status !== 3010) { + // The verbose log is UTF-16; its tail usually names the failing action. + if (existsSync(msiLog)) + log(readFileSync(msiLog, 'utf16le').split(/\r?\n/).slice(-40).join('\n')) + throw new Error(`LibreOffice msiexec failed (exit ${status}); log: ${msiLog}`, { cause: e }) + } + } + mustExist(join(libreOfficeDir, 'program', 'soffice.exe'), 'LibreOffice') + + return { + magickDir, + caesiumExe, + sevenZipDir, + ffmpegBinDir, + mutoolExe, + libreOfficeDir, + ghostscriptDir, + realesrganDir + } +} diff --git a/scripts/verify-bundle.mjs b/scripts/verify-bundle.mjs new file mode 100644 index 0000000..6a5db31 --- /dev/null +++ b/scripts/verify-bundle.mjs @@ -0,0 +1,150 @@ +// @ts-check +/** + * Verify a resources tree carries every bundled tool, and that each one runs. + * + * node scripts/verify-bundle.mjs [resourcesRoot] [--manifest ] + * + * resourcesRoot defaults to ./resources (before packing); the release workflow + * also points it at dist/win-unpacked/resources (after packing), because + * electron-builder only WARNS on a missing extraResources source. `--manifest` + * writes " " for every file, to diff a CI bundle against + * a local one. Exits 1 on any missing file or failed smoke run. + */ +import { + existsSync, + readdirSync, + statSync, + writeFileSync, + mkdtempSync, + mkdirSync, + rmSync +} from 'node:fs' +import { execFileSync } from 'node:child_process' +import { dirname, join, relative, resolve } from 'node:path' +import { tmpdir } from 'node:os' + +const args = process.argv.slice(2) +const mIdx = args.indexOf('--manifest') +const manifest = mIdx >= 0 ? args[mIdx + 1] : null +const root = resolve(args.find((a, i) => !a.startsWith('--') && i !== mIdx + 1) ?? 'resources') +const MB = 1024 * 1024 + +/** [relative path, what breaks without it] */ +const REQUIRED = [ + ['bin/magick.exe', 'every image operation'], + ['bin/CORE_RL_MagickCore_.dll', 'magick (dynamic build runtime)'], + ['bin/modules/coders/IM_MOD_RL_png_.dll', 'magick PNG decode/encode'], + ['bin/modules/coders/IM_MOD_RL_jpeg_.dll', 'magick JPEG decode/encode'], + ['bin/modules/coders/IM_MOD_RL_webp_.dll', 'magick WebP decode/encode'], + ['bin/ffmpeg.exe', 'video/audio convert and compress'], + ['bin/ffprobe.exe', 'video resolution preview'], + ['bin/caesiumclt.exe', 'image compress'], + ['bin/mutool.exe', 'PDF tools'], + ['bin/7z.exe', 'archive tools'], + ['bin/7z.dll', 'archive tools'], + ['bin/7-Zip-License.txt', '7-Zip licence (LGPL + unRAR terms must ship)'], + ['libreoffice/program/soffice.exe', 'document conversion'], + ['libreoffice/program/soffice.com', 'document conversion (console launcher)'], + ['ghostscript/bin/gswin64c.exe', 'PDF compress (non-lossless levels)'], + ['ghostscript/Resource', 'Ghostscript fonts/init'], + ['realesrgan/realesrgan-ncnn-vulkan.exe', 'AI upscale'], + ['realesrgan/models/realesrgan-x4plus.param', 'AI upscale model'], + ['realesrgan/models/realesrgan-x4plus.bin', 'AI upscale model'], + ['realesrgan/models/realesrgan-x4plus-anime.param', 'AI upscale anime model'], + ['realesrgan/models/realesrgan-x4plus-anime.bin', 'AI upscale anime model'], + ['pid/pid_server.py', 'PiD upscaler sidecar'], + ['spandrel/spandrel_server.py', 'spandrel upscaler sidecar'], + ['registry/engines.json', 'built-in model registry'] +] + +const failures = [] +for (const [rel, what] of REQUIRED) + if (!existsSync(join(root, rel))) failures.push(`missing ${rel} (breaks ${what})`) + +// The essentials ffmpeg is ~100 MB; the "full" build (~227 MB) must not leak in. +for (const f of ['bin/ffmpeg.exe', 'bin/ffprobe.exe']) { + const p = join(root, f) + if (existsSync(p) && statSync(p).size > 120 * MB) + failures.push( + `${f} is ${(statSync(p).size / MB).toFixed(0)} MB: the "full" ffmpeg build leaked in` + ) +} + +/** Run a bundled exe; record a failure if it cannot start or exits non-zero. */ +function smoke( + /** @type {string} */ label, + /** @type {string} */ exe, + /** @type {string[]} */ argv, + env = {} +) { + if (!existsSync(exe)) return + try { + const out = execFileSync(exe, argv, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 120_000, + env: { ...process.env, ...env } + }) + console.log(` ✓ ${label}: ${out.split(/\r?\n/).find((l) => l.trim()) ?? 'ok'}`) + } catch (e) { + const err = /** @type {{ status?: number, stderr?: string, message: string }} */ (e) + failures.push( + `${label} failed to run (exit ${err.status ?? '?'}): ${(err.stderr || err.message).trim()}` + ) + } +} + +console.log(`Verifying bundled tools in ${root}`) +const bin = join(root, 'bin') +// The same env the app sets (toolResolver.magickEnv), so the coder modules are +// loaded from THIS tree. Encoding a PNG proves they load, not just magick.exe. +const magickEnv = { + MAGICK_CODER_MODULE_PATH: join(bin, 'modules', 'coders'), + MAGICK_CODER_FILTER_PATH: join(bin, 'modules', 'filters'), + MAGICK_CONFIGURE_PATH: bin +} +const tmp = mkdtempSync(join(tmpdir(), 'filesmith-verify-')) +try { + const png = join(tmp, 'probe.png') + smoke('magick encode PNG', join(bin, 'magick.exe'), ['-size', '8x8', 'xc:red', png], magickEnv) + smoke( + 'magick decode PNG', + join(bin, 'magick.exe'), + [png, '-format', '%wx%h %m', 'info:'], + magickEnv + ) + smoke('ffmpeg', join(bin, 'ffmpeg.exe'), ['-hide_banner', '-version']) + smoke('ffprobe', join(bin, 'ffprobe.exe'), ['-hide_banner', '-version']) + smoke('caesiumclt', join(bin, 'caesiumclt.exe'), ['--version']) + smoke('mutool', join(bin, 'mutool.exe'), ['-v']) + smoke('7z', join(bin, '7z.exe'), ['i']) + smoke('ghostscript', join(root, 'ghostscript', 'bin', 'gswin64c.exe'), ['--version']) + smoke('libreoffice', join(root, 'libreoffice', 'program', 'soffice.com'), ['--version']) +} finally { + rmSync(tmp, { recursive: true, force: true }) +} + +if (manifest) { + const lines = [] + const walk = (/** @type {string} */ d) => { + for (const f of readdirSync(d)) { + const p = join(d, f) + const s = statSync(p) + if (s.isDirectory()) walk(p) + else lines.push(`${s.size} ${relative(root, p).replaceAll('\\', '/')}`) + } + } + walk(root) + lines.sort((a, b) => a.slice(a.indexOf(' ')).localeCompare(b.slice(b.indexOf(' ')))) + mkdirSync(dirname(resolve(manifest)), { recursive: true }) + writeFileSync(manifest, lines.join('\n') + '\n') + const total = lines.reduce((s, l) => s + Number(l.split(' ')[0]), 0) + console.log(` manifest: ${lines.length} files, ${(total / MB).toFixed(1)} MB -> ${manifest}`) +} + +if (failures.length) { + console.log(`\n❌ ${failures.length} problem(s) in ${root}:`) + for (const f of failures) console.log(` • ${f}`) + process.exit(1) +} +console.log('All bundled tools present and runnable.')