Closes the gap from the previous handoff: `npm run dist:all` now produces Linux .deb/AppImage/snap, Windows NSIS/portable/zip, and macOS dmg/zip artifacts. `.github/workflows/release.yml` builds all three on tag push (v*) and attaches the union to a GitHub Release. Bug fix in the same commit (would have broken any packaged build): * `build.files` was `["src/**/*", ...]` — shipped `src/renderer/` .tsx dev source into the asar. Replaced with a whitelist (`src/main/**/*`, `src/preload.js`, `src/plugins/**/*`, `package.json`) and moved the built renderer to `build.extraResources` (`dist/renderer` -> `renderer/`). * `src/main/window/index.js` now uses `process.resourcesPath` when `app.isPackaged` is true, plus a friendly error if the renderer is missing. Verified by extracting the .AppImage and launching the binary — main process logs `Production mode — loading .../resources/renderer/index.html`. New in `package.json` build config: * `mac.target`: dmg + zip for x64 and arm64; `darkModeSupport: true`, `hardenedRuntime: false`, `gatekeeperAssess: false`, `entitlements: null`. `mac.identity: null` retained (unsigned). * `publish`: GitHub Releases provider. CI overrides with `--publish=never`. Known limitations (intentional follow-ups, not blockers): * No `assets/icon.icns` — macOS app uses the default Electron icon. This box lacks `iconutil`/`png2icns`; generating a proper .icns is a one-line follow-up. * No code signing — Windows SmartScreen will warn, macOS Gatekeeper will quarantine. `CSC_LINK_BASE64`/`CSC_KEY_PASSWORD` secrets are already wired in the Windows job for when the cert is added. * No auto-update — user decision. The `publish` block writes the metadata that a future `electron-updater` integration will consume. Verified on this Linux box: * `npm run build:renderer` then `npm run build:linux` — produced AppImage (291M), deb (220M), snap (245M). * `resources/app.asar` has 0 `src/renderer/*` entries (was the pre-fix bug). 44 .tsx files remain in asar — all from `node_modules/@hookform/resolvers` and `node_modules/immer` test fixtures, not our code. * Extracted `squashfs-root/resources/renderer/index.html` exists and is what `window/index.js` loads. * Binary runs for >12s without crashing — `loadFile` resolves `process.resourcesPath/renderer/index.html` correctly. Tests: 306/306 pass. No regressions. Plan doc: docs/plans/2026-06-06-packaging-all-platforms.md (the task-by-task execution guide I followed; included for traceability).
10 KiB
Packaging — All-Platform Build + GitHub Releases
Goal: Make npm run dist:all produce Linux + Windows + macOS artifacts, and have the existing .github/workflows/release.yml create a GitHub Release on tag push with those artifacts. No code signing in this iteration.
Scope (user decisions 2026-06-06):
- Target platforms: all three (Linux .deb/AppImage/snap, Windows NSIS/portable/zip, macOS dmg/zip)
- Distribution: GitHub Releases (no auto-update; static artifacts only)
- Code signing: skip for now (unsigned binaries; Windows SmartScreen will warn, macOS Gatekeeper will quarantine)
Architecture: This is mostly about completing the existing scaffold. electron-builder 26.0.12, scripts/download-tools.js, scripts/generate-icons.js, the package.json scripts, and release.yml are all already in place. We need to (1) close a real bug in the files config, (2) add the macOS job to the workflow, and (3) declare macOS targets in the package config.
Tech stack: electron-builder 26.0.12, GitHub Actions ubuntu-latest/windows-latest/macos-latest runners, softprops/action-gh-release@v2 for the release creation.
Task 1 — Fix the files config in package.json
The current files: ["src/**/*", ...] matches src/renderer/**/*.tsx and ships dev source into the asar. The shipped renderer should be the built dist/renderer/**/* output, not TS source. asarUnpack for ffmpeg-static is correct and stays.
Files: package.json (build.files, build.extraResources)
Step 1.1 — Replace files block
"files": [
"src/main/**/*",
"src/preload.js",
"src/plugins/**/*",
"package.json"
],
"extraResources": [
{
"from": "dist/renderer",
"to": "renderer"
}
]
src/main/**/*— main process source (entry point + all main-process modules)src/preload.js— single preload file at the root ofsrc/src/plugins/**/*— built-in plugins (referenced from main process; theextraFiles: []line in the current config is empty, so this is the right pattern)package.json— required for the asarapp.asar/package.jsonlookup electron does at runtimedist/renderermoves toextraResourcesbecause it should live alongside the asar (not inside it) — the asar is sealed and we want the renderer to be a separately-updatable resource path. Production main loads it vialoadFile(path.join(__dirname, '../../dist/renderer/index.html'))which, in the packaged app, resolves toprocess.resourcesPath/renderer/index.html. This requires changing the loadFile path insrc/main/window/index.jstoo — see Task 4.
Step 1.2 — Verify
npm run build:linuxshould produce a working .AppImage- Inside the .AppImage (extract with
--appimage-extract), checkresources/hasapp.asarand arenderer/directory containingindex.htmlandassets/ - Inside
app.asar, checksrc/main/index.jsis present andsrc/renderer/is absent
Task 2 — Add macOS targets + icon to package.json build config
Files: package.json
Step 2.1 — Replace the existing mac block
"mac": {
"category": "public.app-category.productivity",
"identity": null,
"target": [
{ "target": "dmg", "arch": ["x64", "arm64"] },
{ "target": "zip", "arch": ["x64", "arm64"] }
],
"icon": "assets/icon.icns",
"darkModeSupport": true,
"hardenedRuntime": false,
"gatekeeperAssess": false,
"entitlements": null
}
identity: nullis intentional for unsigned buildsdarkModeSupport: true— the app already supports dark mode; this is just metadatahardenedRuntime: falseandgatekeeperAssess: false— without signing, these are the only way the build succeedsentitlements: null— no entitlements file; some features (jit) won't work but Electron's main process doesn't need them in this app.icnsis missing. For this iteration we'll use the default Electron icon and add a follow-up to generate one. The build will succeed without it but the produced .app will have the default icon.
Step 2.2 — Generate a placeholder .icns (optional, can be deferred)
If we want a proper icon, the path is:
npm run generate-iconsto produceassets/icons/*.png(already done)- Use
png2icns(Linux) oriconutil(mac) to bundle them intoassets/icon.icns - We don't have either on this Linux container; defer to a follow-up plan
For this iteration: ship without .icns and accept the default icon.
Step 2.3 — Add publish config
"publish": {
"provider": "github",
"owner": "amitwh",
"repo": "markdown-converter",
"releaseType": "release"
}
Even without auto-update, this tells electron-builder to write latest-mac.yml / latest-linux.yml next to the artifacts. Useful for future auto-update wiring. CI passes --publish=never to override.
Task 3 — Add macOS job to .github/workflows/release.yml
Files: .github/workflows/release.yml
Step 3.1 — Add a third job between build-windows and release
Insert after the build-windows job (around line 96):
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: Run tests
run: npm test
- name: Build macOS packages (unsigned)
env:
CSC_IDENTITY_AUTO_DISCOVERY: 'false'
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
Step 3.2 — Wire macOS into the release job's needs and downloads
Change the release job:
release:
needs: [build-linux, build-windows, build-macos]
...
And add a third download step (after the Windows download):
- name: Download macOS artifacts
uses: actions/download-artifact@v4
continue-on-error: true
with:
name: macos-artifacts
path: dist
continue-on-error: true is intentional — a failed macOS build shouldn't block a release of working Linux+Windows artifacts.
Task 4 — Update src/main/window/index.js for the resource path
Files: src/main/window/index.js
Step 4.1 — Update the loadFile path
The current code (line 35):
const prodPath = path.join(__dirname, '../../dist/renderer/index.html');
Inside the packaged app, __dirname points into the asar. process.resourcesPath points to the directory holding app.asar + the renderer/ extraResources dir. Change to:
const rendererIndex = app.isPackaged
? path.join(process.resourcesPath, 'renderer', 'index.html')
: path.join(__dirname, '../../dist/renderer/index.html');
win.loadFile(rendererIndex);
This needs to happen after the dev/prod branch is decided. The existing if (!app.isPackaged && devServerUrl) block already handles dev mode; the else branch needs this update.
Step 4.2 — Add a guard for missing renderer in prod
if (app.isPackaged) {
try {
fs.accessSync(path.join(process.resourcesPath, 'renderer', 'index.html'));
} catch {
console.error('[WINDOW] Renderer not found at', process.resourcesPath, '— did you run `npm run build:renderer` before packaging?');
}
}
Failure mode: someone runs npm run build without first building the renderer. Without this guard, the window opens blank and the user has no idea why.
Task 5 — Test on this Linux box
Step 5.1 — Local verification
npm ci
npm run download-tools # idempotent; bin/linux/pandoc already exists
npm run build:renderer
npm run build:linux # produces .deb, .AppImage in dist/
Inspect the .AppImage:
chmod +x dist/*.AppImage
./dist/MarkdownConverter-*.AppImage --appimage-extract
ls squashfs-root/resources/
# Expect: app.asar renderer/
ls squashfs-root/resources/renderer/
# Expect: index.html assets/
Step 5.2 — Run the produced app
./dist/MarkdownConverter-*.AppImage
The app should:
- Launch with the AppShell, header, sidebar
- Allow opening a folder / file
- Export to PDF/DOCX/HTML work (pandoc bundled)
Step 5.3 — Tag + push to test the release workflow (optional)
Only do this if the user wants to verify the full pipeline. A dry-run on the CI side via act would be cleaner but act doesn't run macos-latest jobs locally. If the user wants to verify the workflow, push a v5.0.1-rc1 tag and watch the Actions tab.
Success criteria
npm run dist:allproduces all three platform families on this Linux box (or at least Linux + mac, since Windows is built on Windows)- The .AppImage launches and the app works end-to-end
release.ymlruns all three jobs on a tag push and creates a GitHub release with the union of artifacts- No regressions: 306/306 tests still pass, dev workflow still works
- The
filesbug is fixed: no.tsxin the packaged asar
Known limitations (intentional, can be follow-ups)
- No
.icns— the macOS app will have the default Electron icon. Generating a proper.icnsrequiresiconutil(mac) orpng2icns(cross-platform); neither is on this Linux box. - No code signing — Windows SmartScreen will warn; macOS Gatekeeper will quarantine unsigned
.dmg. Setting up signing later is a config-only change (no structural rework): addCSC_LINK/CSC_KEY_PASSWORDfor Windows,CSC_LINK(Apple Developer ID) for mac. - No auto-update — user decisions for this iteration. Wiring
electron-updateris a follow-up; thepublishconfig from Task 2 puts metadata in the right place. - Cross-build quirks — building macOS from Linux produces a
.dmgthat is generally usable but may show a "this app is from an unidentified developer" prompt the first time. Right-click → Open to bypass once. Documented in the release notes.
Out of scope
- CHANGELOG.md updates (not part of packaging)
- electron-updater wiring
- Code signing cert procurement
- Snap store / Microsoft Store / Mac App Store submission