build(release): dynamic builder config + fix bundled-tool lookup in packages

- electron-builder config moves to electron-builder.config.js so markitdown
  bundling is conditional per platform (PyInstaller only builds for the host
  OS; a missing binary now logs a warning and ships without it instead of
  failing the build); package.json static build section removed, all npm
  scripts pointed at the config; third-party-licenses/ added to packaged files
- FIX (pre-existing): packaged apps looked for bundled pandoc in
  resources/bin, but extraFiles land next to the executable (Contents/ on
  macOS) — packaged builds silently fell back to system pandoc since 4.5.
  New bundledToolDir() resolves the real location for pandoc + markitdown
- download-tools.js pins the win32 pandoc.exe SHA-256 (fetched + verified)
- sharp packaging test accepts sharp 0.35's versioned binding filename
- release.yml: bundle-markitdown step (best-effort) on every OS and a new
  macOS job; release aggregates linux+windows+macos artifacts

Local release artifacts built and verified (dist/):
- MarkdownConverter-4.7.0.AppImage (363MB, pandoc+markitdown bundled,
  packaged app boots clean, tools resolve at the fixed path)
- markdown-converter_4.7.0_amd64.deb (293MB)
- MarkdownConverter-Setup-4.7.0.exe (223MB), portable exe (223MB), zip
  (305MB) — pandoc bundled; markitdown omitted (cannot cross-build),
  legal docs verified inside app.asar

637/637 tests green; lint clean.
This commit is contained in:
2026-09-05 22:48:45 +05:30
parent 1e24b52f3e
commit 0be46d4bac
8 changed files with 246 additions and 174 deletions
+57 -1
View File
@@ -25,6 +25,13 @@ jobs:
- name: Download external tools (pandoc)
run: node scripts/download-tools.js
- name: Bundle MarkItDown (optional, best effort)
# Freezes markitdown + Python into bin/linux/markitdown; failure is
# non-fatal — the build then ships without it and the app falls back
# to a system markitdown at runtime.
run: npm run bundle:markitdown
continue-on-error: true
- name: Run tests
run: npm test
@@ -59,6 +66,12 @@ jobs:
- name: Download external tools (pandoc)
run: node scripts/download-tools.js
- name: Bundle MarkItDown (optional, best effort)
# Windows runners ship python + venv; non-fatal on failure — the
# package then omits the bundled binary by design
run: npm run bundle:markitdown
continue-on-error: true
- name: Run tests
run: npm test
@@ -94,8 +107,44 @@ jobs:
dist/*.zip
retention-days: 5
build-macos:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Install dependencies
run: npm ci
- name: Download external tools (pandoc)
run: node scripts/download-tools.js
- name: Bundle MarkItDown (optional, best effort)
run: npm run bundle:markitdown
continue-on-error: true
- name: Run tests
run: npm test
- name: Build macOS packages
run: npm run build:mac -- --publish=never
- name: Upload macOS artifacts
uses: actions/upload-artifact@v4
with:
name: macos-artifacts
path: |
dist/*.dmg
dist/*.zip
retention-days: 5
release:
needs: [build-linux, build-windows]
needs: [build-linux, build-windows, build-macos]
if: always()
runs-on: ubuntu-latest
steps:
@@ -115,6 +164,13 @@ jobs:
name: windows-artifacts
path: dist
- name: Download macOS artifacts
uses: actions/download-artifact@v4
continue-on-error: true
with:
name: macos-artifacts
path: dist
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
+124
View File
@@ -0,0 +1,124 @@
/**
* electron-builder configuration (replaces the static package.json "build"
* section so it can be computed dynamically).
*
* Why a JS config: the bundled markitdown binary is OPTIONAL at build time —
* PyInstaller can only build for the host OS, so e.g. a Windows package
* built on a machine without bin/win32/markitdown.exe must simply omit it
* (the app falls back to system-installed markitdown at runtime) instead of
* failing on a missing extraFiles source.
*
* Everything else mirrors the previous static config verbatim.
*/
const fs = require('fs');
const path = require('path');
/** Common extraFiles for a platform: pandoc always, markitdown when built. */
function toolExtraFiles(platform) {
const exe = platform === 'win32' ? '.exe' : '';
const files = [{ from: `bin/${platform}/pandoc${exe}`, to: `bin/pandoc${exe}` }];
const markitdown = `bin/${platform}/markitdown${exe}`;
if (fs.existsSync(path.join(__dirname, markitdown))) {
files.push({ from: markitdown, to: `bin/markitdown${exe}` });
} else {
console.warn(
`[electron-builder.config] ${markitdown} not found — building WITHOUT the ` +
'bundled markitdown (the app will use a system install if present). ' +
'Run "npm run bundle:markitdown" on this platform to bundle it.'
);
}
return files;
}
module.exports = {
appId: 'com.concreteinfo.markdownconverter',
productName: 'MarkdownConverter',
copyright: 'Copyright (C) 2024-2025 ConcreteInfo',
directories: { output: 'dist' },
icon: 'assets/icon',
files: [
'src/**/*',
'assets/**/*',
'scripts/**/*',
'node_modules/**/*',
'package.json',
'THIRD-PARTY-NOTICES.md',
'SOURCES.md',
'third-party-licenses/**/*',
],
asarUnpack: [
'node_modules/ffmpeg-static/**',
'node_modules/sharp/**',
'node_modules/@img/**',
'node_modules/@napi-rs/**',
'assets/fonts/**',
],
fileAssociations: [
{
ext: 'md',
name: 'Markdown Document',
description: 'Markdown Document',
mimeType: 'text/markdown',
role: 'Editor',
},
{
ext: 'markdown',
name: 'Markdown Document',
description: 'Markdown Document',
mimeType: 'text/markdown',
role: 'Editor',
},
{
ext: 'pdf',
name: 'PDF Document',
description: 'PDF Document',
mimeType: 'application/pdf',
role: 'Editor',
},
],
mac: {
category: 'public.app-category.productivity',
identity: null,
extraFiles: toolExtraFiles('darwin'),
},
win: {
target: [
{ target: 'nsis', arch: ['x64'] },
{ target: 'portable', arch: ['x64'] },
{ target: 'zip', arch: ['x64'] },
],
artifactName: '${productName}-${version}-${arch}.${ext}',
requestedExecutionLevel: 'asInvoker',
legalTrademarks: 'Copyright (C) 2024-2025 ConcreteInfo',
verifyUpdateCodeSignature: false,
signAndEditExecutable: false,
extraFiles: toolExtraFiles('win32'),
},
nsis: {
oneClick: false,
perMachine: false,
allowToChangeInstallationDirectory: true,
displayLanguageSelector: true,
createDesktopShortcut: true,
createStartMenuShortcut: true,
shortcutName: 'MarkdownConverter',
runAfterFinish: true,
menuCategory: 'Productivity',
license: 'LICENSE',
warningsAsErrors: false,
artifactName: '${productName}-Setup-${version}.${ext}',
deleteAppDataOnUninstall: false,
differentialPackage: true,
},
linux: {
target: ['deb', 'AppImage', 'snap'],
category: 'Utility',
maintainer: 'ConcreteInfo <amit.wh@gmail.com>',
extraFiles: toolExtraFiles('linux'),
},
deb: {
depends: ['imagemagick', 'libreoffice-common'],
description: 'Professional Markdown editor and universal file converter',
maintainer: 'ConcreteInfo <amit.wh@gmail.com>',
},
};
+10 -153
View File
@@ -12,17 +12,17 @@
"lint:fix": "eslint src tests --fix",
"format": "prettier --write src tests",
"format:check": "prettier --check src tests",
"build": "electron-builder",
"build:win": "electron-builder --win",
"build:win-signed": "cross-env CSC_LINK=code-signing-cert.pfx electron-builder --win",
"build:win-unsigned": "cross-env CSC_IDENTITY_AUTO_DISCOVERY=false electron-builder --win",
"build": "electron-builder --config electron-builder.config.js",
"build:win": "electron-builder --config electron-builder.config.js --win",
"build:win-signed": "cross-env CSC_LINK=code-signing-cert.pfx electron-builder --config electron-builder.config.js --win",
"build:win-unsigned": "cross-env CSC_IDENTITY_AUTO_DISCOVERY=false electron-builder --config electron-builder.config.js --win",
"create-cert": "powershell -ExecutionPolicy Bypass -File scripts/create-selfsigned-cert.ps1",
"build:mac": "electron-builder --mac",
"build:linux": "electron-builder --linux",
"build:linux-ci": "electron-builder --linux deb AppImage",
"build:local": "electron-builder --linux --win",
"dist": "electron-builder --publish=never",
"dist:all": "electron-builder -mwl",
"build:mac": "electron-builder --config electron-builder.config.js --mac",
"build:linux": "electron-builder --config electron-builder.config.js --linux",
"build:linux-ci": "electron-builder --config electron-builder.config.js --linux deb AppImage",
"build:local": "electron-builder --config electron-builder.config.js --linux --win",
"dist": "electron-builder --config electron-builder.config.js --publish=never",
"dist:all": "electron-builder --config electron-builder.config.js -mwl",
"download-tools": "node scripts/download-tools.js",
"bundle:markitdown": "node scripts/bundle-markitdown.js",
"generate-icons": "node scripts/generate-icons.js"
@@ -99,148 +99,5 @@
"lodash.pick": "npm:lodash@^4.17.21",
"lodash-es": "^4.18.1",
"lodash": "^4.17.21"
},
"build": {
"appId": "com.concreteinfo.markdownconverter",
"productName": "MarkdownConverter",
"copyright": "Copyright (C) 2024-2025 ConcreteInfo",
"directories": {
"output": "dist"
},
"icon": "assets/icon",
"files": [
"src/**/*",
"assets/**/*",
"scripts/**/*",
"node_modules/**/*",
"package.json",
"THIRD-PARTY-NOTICES.md",
"SOURCES.md"
],
"asarUnpack": [
"node_modules/ffmpeg-static/**",
"node_modules/sharp/**",
"node_modules/@img/**",
"node_modules/@napi-rs/**",
"assets/fonts/**"
],
"extraFiles": [],
"fileAssociations": [
{
"ext": "md",
"name": "Markdown Document",
"description": "Markdown Document",
"mimeType": "text/markdown",
"role": "Editor"
},
{
"ext": "markdown",
"name": "Markdown Document",
"description": "Markdown Document",
"mimeType": "text/markdown",
"role": "Editor"
},
{
"ext": "pdf",
"name": "PDF Document",
"description": "PDF Document",
"mimeType": "application/pdf",
"role": "Editor"
}
],
"mac": {
"category": "public.app-category.productivity",
"identity": null,
"extraFiles": [
{
"from": "bin/darwin/pandoc",
"to": "bin/pandoc"
},
{
"from": "bin/darwin/markitdown",
"to": "bin/markitdown"
}
]
},
"win": {
"target": [
{
"target": "nsis",
"arch": [
"x64"
]
},
{
"target": "portable",
"arch": [
"x64"
]
},
{
"target": "zip",
"arch": [
"x64"
]
}
],
"artifactName": "${productName}-${version}-${arch}.${ext}",
"requestedExecutionLevel": "asInvoker",
"legalTrademarks": "Copyright (C) 2024-2025 ConcreteInfo",
"verifyUpdateCodeSignature": false,
"signAndEditExecutable": false,
"extraFiles": [
{
"from": "bin/win32/pandoc.exe",
"to": "bin/pandoc.exe"
},
{
"from": "bin/win32/markitdown.exe",
"to": "bin/markitdown.exe"
}
]
},
"nsis": {
"oneClick": false,
"perMachine": false,
"allowToChangeInstallationDirectory": true,
"displayLanguageSelector": true,
"createDesktopShortcut": true,
"createStartMenuShortcut": true,
"shortcutName": "MarkdownConverter",
"runAfterFinish": true,
"menuCategory": "Productivity",
"license": "LICENSE",
"warningsAsErrors": false,
"artifactName": "${productName}-Setup-${version}.${ext}",
"deleteAppDataOnUninstall": false,
"differentialPackage": true
},
"linux": {
"target": [
"deb",
"AppImage",
"snap"
],
"category": "Utility",
"maintainer": "ConcreteInfo <amit.wh@gmail.com>",
"extraFiles": [
{
"from": "bin/linux/pandoc",
"to": "bin/pandoc"
},
{
"from": "bin/linux/markitdown",
"to": "bin/markitdown"
}
]
},
"deb": {
"depends": [
"imagemagick",
"libreoffice-common"
],
"description": "Professional Markdown editor and universal file converter",
"maintainer": "ConcreteInfo <amit.wh@gmail.com>"
}
}
}
+1 -1
View File
@@ -27,9 +27,9 @@ const PANDOC_VERSION = '3.9.0.2';
*/
const KNOWN_SHA256 = {
'linux:pandoc': '7d124235998ecd3cdd9a463b1e5f6691a178b6461824c29a36170a0882f05597',
'win32:pandoc.exe': 'e83f8354c0f507222b5684797b9c5ae766f03889785995d14aac27816ec456ba',
// Fill these from a trusted machine after the first download of each
// platform (the script prints the computed hash):
// 'win32:pandoc.exe': '…',
// 'darwin:pandoc': '…',
'fonts:FiraCode-Regular.ttf': '3c79d234a9161c790410ebb2a80de7efb7c15f581062c130e0fa78503ccdd0da',
'fonts:FiraCode-Bold.ttf': '975f26779fac1029c2cbdac1e9fac7e9ddeec05e064675e4aac63bffa121742f',
+17 -1
View File
@@ -27,11 +27,27 @@ if (process.platform === 'win32') {
}
}
/**
* Directory of the bundled external tools (pandoc/markitdown) in a PACKAGED
* app. electron-builder extraFiles land next to the executable on Linux and
* Windows, and inside Contents/ on macOS — NOT in resources/bin (which is
* where the lookup incorrectly pointed before v4.7, making packaged builds
* silently fall back to system-installed tools).
*/
function bundledToolDir() {
if (process.platform === 'darwin') {
// resourcesPath = …/Contents/Resources → tools live in …/Contents/bin
return path.join(path.dirname(process.resourcesPath), 'bin');
}
// exePath = …/MarkdownConverter(.exe) → tools live in …/bin
return path.join(path.dirname(process.execPath), 'bin');
}
// Returns path to pandoc: bundled binary when packaged, dev bin or system fallback otherwise.
function getPandocPath() {
if (app.isPackaged) {
const ext = process.platform === 'win32' ? '.exe' : '';
return path.join(process.resourcesPath, 'bin', `pandoc${ext}`);
return path.join(bundledToolDir(), `pandoc${ext}`);
}
// Development: prefer locally-downloaded binary in bin/<platform>/
const devBin = path.join(
+20 -13
View File
@@ -24,25 +24,32 @@ const MAX_OUTPUT_BUFFER = 20 * 1024 * 1024;
/**
* Path to the bundled markitdown binary (built by scripts/bundle-markitdown.js
* via PyInstaller), when the app ships one. Mirrors getPandocPath's layout:
* dev: bin/<platform>/markitdown · packaged: <resourcesPath>/bin/markitdown.
* Returns null when no bundle exists (PATH/python fallbacks apply).
* via PyInstaller), when the app ships one. Layout matches getPandocPath:
* dev: bin/<platform>/markitdown · packaged: <app dir>/bin/markitdown (next
* to the executable on Linux/Windows, Contents/bin on macOS — the same
* extraFiles destination). Returns null when absent (PATH/python fallbacks).
*/
function getBundledMarkItDownPath() {
const pathUtil = require('path');
const fs = require('fs');
const exe = process.platform === 'win32' ? 'markitdown.exe' : 'markitdown';
if (typeof process === 'object' && process.resourcesPath && !process.resourcesPath.includes('node_modules')) {
// Packaged (Electron) — resourcesPath only exists in a real app runtime
try {
const electron = require('electron');
if (electron.app?.isPackaged) {
const packaged = pathUtil.join(process.resourcesPath, 'bin', exe);
if (fs.existsSync(packaged)) return packaged;
}
} catch {
/* not running under Electron (tests) — fall through to dev layout */
try {
// Only meaningful under Electron; tests import this module under Node
const electron = require('electron');
if (electron.app?.isPackaged) {
const base =
process.platform === 'darwin'
? pathUtil.dirname(electron.app.getPath('exe')) // …/Contents/MacOS
: pathUtil.dirname(process.execPath);
const packaged =
process.platform === 'darwin'
? pathUtil.join(base, '..', 'bin', exe) // …/Contents/bin
: pathUtil.join(base, 'bin', exe);
if (fs.existsSync(packaged)) return packaged;
return null;
}
} catch {
/* not running under Electron (tests) — use the dev layout below */
}
const dev = pathUtil.join(__dirname, '..', '..', 'bin', process.platform, exe);
return fs.existsSync(dev) ? dev : null;
+12 -3
View File
@@ -87,9 +87,18 @@ describe('Legal compliance artifacts', () => {
expect(sources).toMatch(/https:\/\/ffmpeg\.org\/releases\//);
});
test('packaging includes the legal documents in build.files', () => {
test('packaging includes the legal documents in build files', () => {
// The build config lives in electron-builder.config.js (dynamic — it
// conditionally bundles the markitdown binary per platform)
const configSrc = fs.readFileSync(
path.join(rootDir, 'electron-builder.config.js'),
'utf-8'
);
expect(configSrc).toContain("'THIRD-PARTY-NOTICES.md'");
expect(configSrc).toContain("'SOURCES.md'");
expect(configSrc).toContain('third-party-licenses');
// And package.json must not carry a stale static build section anymore
const pkg = JSON.parse(fs.readFileSync(path.join(rootDir, 'package.json'), 'utf-8'));
expect(pkg.build.files).toContain('THIRD-PARTY-NOTICES.md');
expect(pkg.build.files).toContain('SOURCES.md');
expect(pkg.build).toBeUndefined();
});
});
+5 -2
View File
@@ -50,8 +50,11 @@ function listFilesRecursively(dir) {
);
test('sharp-linux-x64 ships its native binding', () => {
const binding = path.join(UNPACKED_IMG_DIR, 'sharp-linux-x64', 'lib', 'sharp-linux-x64.node');
expect(fs.existsSync(binding)).toBe(true);
// sharp 0.35 names the binding with a version suffix
// (sharp-linux-x64-0.35.4.node); 0.34 used a bare name — accept both
const libDir = path.join(UNPACKED_IMG_DIR, 'sharp-linux-x64', 'lib');
const bindings = fs.readdirSync(libDir).filter((f) => /^sharp-linux-x64.*\.node$/.test(f));
expect(bindings.length).toBeGreaterThan(0);
});
test('sharp-libvips-linux-x64 ships the bundled libvips shared libraries', () => {