From 7b300fe77a551ab9344b5d37749fc9737d537719 Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Sun, 25 Oct 2026 17:12:16 -0400 Subject: [PATCH 1/2] chore(packaging): trim packaged node_modules Let electron-builder's pnpm production collector own the dependency closure, reclassify renderer-only packages as dev dependencies, and verify the packaged asar in CI. --- .github/workflows/build.yml | 7 + .github/workflows/publish.yml | 7 + CONTRIBUTING.md | 17 +++ electron-builder.js | 9 +- package.json | 10 +- pnpm-lock.yaml | 24 +-- scripts/verifyPackagedDependencies.js | 210 ++++++++++++++++++++++++++ vite.config.mts | 5 +- 8 files changed, 263 insertions(+), 26 deletions(-) create mode 100644 scripts/verifyPackagedDependencies.js diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5b32fadd9..d97196187 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -29,16 +29,19 @@ jobs: platform: macOS package-cmd: vp run package:macos --publish=never -c.mac.identity=null cleanup-path: dist/mac-universal + verify-path: dist/mac-universal/Gitify.app artifact-name: Gitify-dist-mac - os: windows-latest platform: Windows package-cmd: vp run package:win --publish=never cleanup-path: dist/win-unpacked + verify-path: dist/win-unpacked artifact-name: Gitify-dist-win - os: ubuntu-latest platform: Linux package-cmd: vp run package:linux --publish=never cleanup-path: dist/linux-unpacked + verify-path: dist/linux-unpacked artifact-name: Gitify-dist-linux runs-on: ${{ matrix.os }} @@ -63,6 +66,10 @@ jobs: CSC_LINK: ${{ matrix.platform == 'macOS' && secrets.CSC_LINK || (matrix.platform == 'windows' && secrets.WIN_CSC_LINK || '') }} CSC_KEY_PASSWORD: ${{ matrix.platform == 'macOS' && secrets.CSC_KEY_PASSWORD || (matrix.platform == 'windows' && secrets.WIN_CSC_KEY_PASSWORD || '') }} + - name: Verify packaged dependencies + shell: bash + run: node scripts/verifyPackagedDependencies.js "${{ matrix.verify-path }}" + - name: Clean up builds shell: bash run: rm -rfv ${{ matrix.cleanup-path }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index cc267d659..e4f6f93da 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -46,16 +46,19 @@ jobs: - os: macos-latest platform: macOS package-cmd: vp run package:macos --publish onTagOrDraft + verify-path: dist/mac-universal/Gitify.app artifact-name: Gitify-release-mac use-apple-notarization: true - os: windows-latest platform: Windows package-cmd: vp run package:win --publish onTagOrDraft + verify-path: dist/win-unpacked artifact-name: Gitify-release-win use-apple-notarization: false - os: ubuntu-latest platform: Linux package-cmd: vp run package:linux --publish onTagOrDraft + verify-path: dist/linux-unpacked artifact-name: Gitify-release-linux use-apple-notarization: false runs-on: ${{ matrix.os }} @@ -91,6 +94,10 @@ jobs: # General GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Verify packaged dependencies + shell: bash + run: node scripts/verifyPackagedDependencies.js "${{ matrix.verify-path }}" + - name: Upload artifacts uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 63720a43e..57ed80882 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -91,6 +91,23 @@ guard fails the run outside Linux rather than letting that happen. Note that these baselines capture the `backdrop-filter` fallback for Glass, not the macOS native vibrancy material, which Chromium cannot render. +### 📦 Dependencies and packaging + +The packaged app ships only the packages that are loaded from disk at runtime. +Declare main-process runtime dependencies in `dependencies`; packages that Vite +bundles into the renderer belong in `devDependencies`, even though the renderer +imports them. The current runtime packages are `electron-log`, +`electron-menubar`, and `electron-updater`; `cn`, `react`, `react-dom`, and +`react-router-dom` are renderer-only and build-time. + +`electron-builder` collects the production closure from `dependencies` through +its pnpm collector. Do not add `node_modules` to the `files` list in +`electron-builder.js` — a hand-written glob overrides that collector and can +re-introduce dev dependencies. After packaging, run +`node scripts/verifyPackagedDependencies.js ` to assert that the asar +contains the runtime packages and none of the renderer-only packages or the pnpm +virtual store. + ### 🎨 Code Style & Conventions - Linting and formatting are configured in `vite.config.mts` (the `lint` and `fmt` blocks). Please run `pnpm check` before submitting a PR. diff --git a/electron-builder.js b/electron-builder.js index dc20ce5d4..688a0b802 100644 --- a/electron-builder.js +++ b/electron-builder.js @@ -6,14 +6,7 @@ const config = { appId: 'com.electron.gitify', copyright: 'Copyright © 2026 Gitify Team', asar: true, - files: [ - 'assets/images/*', - 'assets/sounds/*', - 'build/**/*', - 'LICENSE', - 'node_modules/**/*', // Ideally we would have !node_modules and let electron-builder prune deps - 'package.json', - ], + files: ['assets/images/*', 'assets/sounds/*', 'build/**/*', 'LICENSE', 'package.json'], electronLanguages: ['en'], protocols: [ { diff --git a/package.json b/package.json index d7ea4cc05..f62c347ab 100644 --- a/package.json +++ b/package.json @@ -75,13 +75,9 @@ "test:visual:update": "scripts/visual.sh --update" }, "dependencies": { - "cn": "0.5.0", "electron-log": "5.4.4", "electron-menubar": "11.1.2", - "electron-updater": "6.8.11", - "react": "19.3.1", - "react-dom": "19.3.1", - "react-router-dom": "7.18.4" + "electron-updater": "6.8.11" }, "devDependencies": { "@discordapp/twemoji": "16.0.1", @@ -119,6 +115,7 @@ "@vitejs/plugin-react": "6.1.2", "@vitest/browser-playwright": "catalog:", "@vitest/coverage-v8": "catalog:", + "cn": "0.5.0", "concurrently": "10.0.6", "date-fns": "4.4.0", "dotenv": "18.0.9", @@ -130,7 +127,10 @@ "happy-dom": "21.0.4", "husky": "9.1.7", "playwright": "1.64.1", + "react": "19.3.1", "react-devtools": "8.0.0", + "react-dom": "19.3.1", + "react-router-dom": "7.18.4", "rimraf": "6.1.3", "semver": "7.8.5", "tailwindcss": "4.3.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 196b5af64..5a7a3f7c6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -192,9 +192,6 @@ importers: .: dependencies: - cn: - specifier: 0.5.0 - version: 0.5.0 electron-log: specifier: 5.4.4 version: 5.4.4 @@ -204,15 +201,6 @@ importers: electron-updater: specifier: 6.8.11 version: 6.8.11(supports-color@10.2.2) - react: - specifier: 19.3.1 - version: 19.3.1 - react-dom: - specifier: 19.3.1 - version: 19.3.1(react@19.3.1) - react-router-dom: - specifier: 7.18.4 - version: 7.18.4(react-dom@19.3.1(react@19.3.1))(react@19.3.1) devDependencies: '@discordapp/twemoji': specifier: 16.0.1 @@ -319,6 +307,9 @@ importers: '@vitest/coverage-v8': specifier: 'catalog:' version: 5.0.5(@vitest/browser@5.0.5)(vitest@5.0.5) + cn: + specifier: 0.5.0 + version: 0.5.0 concurrently: specifier: 10.0.6 version: 10.0.6 @@ -352,9 +343,18 @@ importers: playwright: specifier: 1.64.1 version: 1.64.1 + react: + specifier: 19.3.1 + version: 19.3.1 react-devtools: specifier: 8.0.0 version: 8.0.0(supports-color@10.2.2) + react-dom: + specifier: 19.3.1 + version: 19.3.1(react@19.3.1) + react-router-dom: + specifier: 7.18.4 + version: 7.18.4(react-dom@19.3.1(react@19.3.1))(react@19.3.1) rimraf: specifier: 6.1.3 version: 6.1.3 diff --git a/scripts/verifyPackagedDependencies.js b/scripts/verifyPackagedDependencies.js new file mode 100644 index 000000000..60cf6bf46 --- /dev/null +++ b/scripts/verifyPackagedDependencies.js @@ -0,0 +1,210 @@ +#!/usr/bin/env node + +/** + * Verifies that a packaged Gitify app ships only its runtime dependency closure. + * + * The packaged app must contain the main-process runtime packages and must not + * contain the renderer-only packages that Vite bundles, the pnpm virtual store, + * or build tooling. Run this after electron-builder has produced an unpacked app: + * + * node scripts/verifyPackagedDependencies.js dist/mac-universal/Gitify.app + * node scripts/verifyPackagedDependencies.js dist/win-unpacked + * node scripts/verifyPackagedDependencies.js dist/linux-unpacked + * node scripts/verifyPackagedDependencies.js path/to/app.asar + * + * With no argument it auto-detects the first app.asar under dist/. + */ + +const fs = require('node:fs'); +const path = require('node:path'); +const { createRequire } = require('node:module'); + +const REQUIRED_PACKAGES = ['electron-log', 'electron-menubar', 'electron-updater']; +const FORBIDDEN_PACKAGES = ['cn', 'react', 'react-dom', 'react-router-dom']; +const BUILD_ONLY_PACKAGES = ['electron', 'electron-builder', 'typescript', 'vite', 'vitest']; +const BUILD_ONLY_SCOPES = ['@electron/', '@graphql-codegen/', '@primer/', '@tailwindcss/']; +const MAX_NODE_MODULES_BYTES = 10 * 1024 * 1024; + +function usage() { + console.log(`Usage: node scripts/verifyPackagedDependencies.js [app-dir-or-asar] + +Checks a packaged app's app.asar for the runtime dependency surface. + +Accepted paths: + - a packaged app directory (macOS .app, win-unpacked, linux-unpacked) + - an app.asar file + - omit to auto-detect the first app.asar under dist/ + +Exit codes: 0 pass, 1 dependency check failed, 2 usage or read error.`); +} + +function resolveAsarPath(input) { + const candidates = []; + + if (input) { + const resolved = path.resolve(input); + + if (fs.existsSync(resolved) && fs.statSync(resolved).isFile()) { + if (!resolved.endsWith('.asar')) { + throw new Error(`Not an asar archive: ${resolved}`); + } + return resolved; + } + + candidates.push( + path.join(resolved, 'Contents', 'Resources', 'app.asar'), + path.join(resolved, 'resources', 'app.asar'), + path.join(resolved, 'Resources', 'app.asar'), + ); + } else { + const dist = path.resolve('dist'); + if (fs.existsSync(dist)) { + for (const name of fs.readdirSync(dist)) { + const dir = path.join(dist, name); + if (!fs.statSync(dir).isDirectory()) { + continue; + } + candidates.push( + path.join(dir, 'Gitify.app', 'Contents', 'Resources', 'app.asar'), + path.join(dir, 'resources', 'app.asar'), + ); + } + } + } + + const found = candidates.find((candidate) => fs.existsSync(candidate)); + if (!found) { + throw new Error( + input + ? `No app.asar found under ${path.resolve(input)}` + : 'No app.asar found under dist/. Package the app first or pass an app path.', + ); + } + return found; +} + +function loadAsar() { + try { + const fromElectronBuilder = createRequire(require.resolve('electron-builder/package.json')); + return fromElectronBuilder('@electron/asar'); + } catch (error) { + throw new Error(`Could not load @electron/asar through electron-builder: ${error.message}`); + } +} + +function normalizeEntry(entry) { + return `/${entry.replaceAll('\\', '/').replace(/^\/+/, '')}`; +} + +function topLevelPackageName(entry) { + const parts = entry.split('/').filter(Boolean); + if (parts[0] !== 'node_modules' || parts.length < 2) { + return null; + } + if (parts[1].startsWith('@')) { + return parts.length > 2 ? `${parts[1]}/${parts[2]}` : null; + } + return parts[1]; +} + +function formatBytes(bytes) { + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +function checkPackagedDependencies(asarPath) { + const asar = loadAsar(); + const entries = asar.listPackage(asarPath).map(normalizeEntry); + const nodeModuleEntries = entries.filter((entry) => entry.startsWith('/node_modules/')); + const packages = new Set( + nodeModuleEntries.map(topLevelPackageName).filter((name) => name != null), + ); + + const violations = []; + + if (packages.size === 0) { + violations.push('No node_modules packages were found in the archive.'); + } + + if (nodeModuleEntries.some((entry) => entry.startsWith('/node_modules/.pnpm/'))) { + violations.push('The pnpm virtual store (/node_modules/.pnpm) must not ship.'); + } + + for (const name of REQUIRED_PACKAGES) { + if (!packages.has(name)) { + violations.push(`Missing runtime package: ${name}`); + } + } + + for (const name of FORBIDDEN_PACKAGES) { + if (packages.has(name)) { + violations.push(`Renderer-only package must not ship: ${name}`); + } + } + + for (const name of BUILD_ONLY_PACKAGES) { + if (packages.has(name)) { + violations.push(`Build-only package must not ship: ${name}`); + } + } + + for (const name of packages) { + if (BUILD_ONLY_SCOPES.some((scope) => name.startsWith(scope))) { + violations.push(`Build-only scoped package must not ship: ${name}`); + } + } + + let nodeModulesBytes = 0; + for (const entry of nodeModuleEntries) { + const stat = asar.statFile(asarPath, entry.replace(/^\//, '')); + if (stat && typeof stat.size === 'number') { + nodeModulesBytes += stat.size; + } + } + + if (nodeModulesBytes > MAX_NODE_MODULES_BYTES) { + violations.push( + `node_modules is ${formatBytes(nodeModulesBytes)}, over the ${formatBytes(MAX_NODE_MODULES_BYTES)} budget.`, + ); + } + + return { packages, nodeModulesBytes, violations }; +} + +function main() { + const args = process.argv.slice(2); + if (args.includes('--help') || args.includes('-h')) { + usage(); + return; + } + + try { + const asarPath = resolveAsarPath(args[0]); + const { packages, nodeModulesBytes, violations } = checkPackagedDependencies(asarPath); + + console.log('Packaged dependency check'); + console.log(` asar: ${asarPath}`); + console.log(` node_modules packages: ${packages.size}`); + console.log(` node_modules size: ${formatBytes(nodeModulesBytes)}`); + console.log(` runtime packages: ${REQUIRED_PACKAGES.join(', ')}`); + + if (violations.length > 0) { + console.error('\n[FAIL] Packaged dependency surface is invalid:'); + for (const violation of violations) { + console.error(` - ${violation}`); + } + process.exitCode = 1; + return; + } + + console.log('\n[PASS] Packaged dependency surface is valid.'); + } catch (error) { + console.error(`[ERROR] ${error.message}`); + process.exitCode = 2; + } +} + +if (require.main === module) { + main(); +} + +module.exports = { checkPackagedDependencies, resolveAsarPath }; diff --git a/vite.config.mts b/vite.config.mts index 20736220b..2a863663b 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -208,7 +208,10 @@ export default defineConfig({ output: { entryFileNames: 'main.js' }, external: [ 'electron', - // TODO - how many of these are truly needed? + // Main-process runtime dependencies. Kept external so + // electron-builder's production dependency collector packages + // them; the renderer and preload are bundled. See + // electron-builder.js. 'electron-log', 'electron-menubar', 'electron-updater', From 858d5c03c9efb40e60a5eded46a0f27c25d6d025 Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Sun, 25 Oct 2026 17:27:14 -0400 Subject: [PATCH 2/2] fix(packaging): read asar sizes from the header statFile could not resolve normalized paths on Windows; traverse the archive header instead. --- scripts/verifyPackagedDependencies.js | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/scripts/verifyPackagedDependencies.js b/scripts/verifyPackagedDependencies.js index 60cf6bf46..bc3b167c8 100644 --- a/scripts/verifyPackagedDependencies.js +++ b/scripts/verifyPackagedDependencies.js @@ -111,6 +111,19 @@ function formatBytes(bytes) { return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } +function entryBytes(entry) { + if (!entry) { + return 0; + } + if (typeof entry.size === 'number') { + return entry.size; + } + if (entry.files) { + return Object.values(entry.files).reduce((total, child) => total + entryBytes(child), 0); + } + return 0; +} + function checkPackagedDependencies(asarPath) { const asar = loadAsar(); const entries = asar.listPackage(asarPath).map(normalizeEntry); @@ -153,13 +166,8 @@ function checkPackagedDependencies(asarPath) { } } - let nodeModulesBytes = 0; - for (const entry of nodeModuleEntries) { - const stat = asar.statFile(asarPath, entry.replace(/^\//, '')); - if (stat && typeof stat.size === 'number') { - nodeModulesBytes += stat.size; - } - } + const header = asar.getRawHeader(asarPath).header; + const nodeModulesBytes = entryBytes(header.files?.node_modules); if (nodeModulesBytes > MAX_NODE_MODULES_BYTES) { violations.push(