add codesigning to gh action

This commit is contained in:
Ramnique Singh 2026-01-19 18:32:09 +05:30
parent 7387d3c1c9
commit fe77a7a419
4 changed files with 159 additions and 96 deletions

View file

@ -53,11 +53,45 @@ jobs:
console.log('Updated version to:', version); console.log('Updated version to:', version);
" "
- name: Import Code Signing Certificate
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
run: |
# Create a temporary keychain
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
KEYCHAIN_PASSWORD=$(openssl rand -base64 32)
# Create keychain
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
# Decode and import certificate
echo "$APPLE_CERTIFICATE" | base64 --decode > $RUNNER_TEMP/certificate.p12
security import $RUNNER_TEMP/certificate.p12 -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH"
# Allow codesign to access the keychain
security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
# Add keychain to search list
security list-keychain -d user -s "$KEYCHAIN_PATH" login.keychain
# Verify certificate was imported
security find-identity -v "$KEYCHAIN_PATH"
# Clean up certificate file
rm -f $RUNNER_TEMP/certificate.p12
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
working-directory: apps/x working-directory: apps/x
- name: Build distributables - name: Build distributables
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: npm run make run: npm run make
working-directory: apps/x/apps/main working-directory: apps/x/apps/main
@ -109,3 +143,11 @@ jobs:
files: apps/x/apps/main/out/make/* files: apps/x/apps/main/out/make/*
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Cleanup keychain
if: always()
run: |
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
if [ -f "$KEYCHAIN_PATH" ]; then
security delete-keychain "$KEYCHAIN_PATH" || true
fi

View file

@ -39,16 +39,20 @@ Prepares all build artifacts in a hidden `.package/` staging directory:
- Copies preload/renderer dist to `.package/` - Copies preload/renderer dist to `.package/`
### 2. `packageAfterCopy` Hook (Post-copy) ### 2. `packageAfterCopy` Hook (Post-copy)
After Forge copies source to output, this hook fixes it: After Forge copies source to output, this hook replaces source files with bundled/staged files:
- Removes unbundled `dist/` (has unresolvable `@x/core` imports) - **Hook signature**: `async (config, buildPath, electronVersion, platform, arch)`
- Copies bundled `dist-bundle/`, `preload/`, `renderer/` from staging - `buildPath` already points to `Contents/Resources/app` (not the `.app` bundle root)
- Removes unbundled `dist/` directory (has unresolvable `@x/core` imports)
- Copies bundled `dist-bundle/` from `.package/` staging directory
- Copies `preload/` and `renderer/` directories from staging
- Updates `package.json`: sets `main` to `dist-bundle/main.js`, removes - Updates `package.json`: sets `main` to `dist-bundle/main.js`, removes
`"type": "module"` (since we bundle as CJS), removes dependencies `"type": "module"` (since we bundle as CJS), removes all dependencies/devDependencies
- Cleans up source files (tsconfig.json, src/, etc.) - Cleans up source files (src/, tsconfig.json, forge.config.cjs, agents.md, .gitignore, bundle.mjs)
**Why this approach?** Electron Forge ignores `packagerConfig.dir` and always **Why this approach?** Electron Forge ignores `packagerConfig.dir` and always
packages from the config file's directory. The `packageAfterCopy` hook is the packages from the config file's directory. The `packageAfterCopy` hook is the
reliable way to customize the packaged output. reliable way to customize the packaged output by modifying files after Forge
copies the source directory but before the app bundle is finalized.
## Staged Build Directory (`.package/`) ## Staged Build Directory (`.package/`)

View file

@ -11,6 +11,15 @@ module.exports = {
icon: './icons/icon', // .icns extension added automatically icon: './icons/icon', // .icns extension added automatically
appBundleId: 'com.rowboat.app', appBundleId: 'com.rowboat.app',
appCategoryType: 'public.app-category.productivity', appCategoryType: 'public.app-category.productivity',
osxSign: {},
osxNotarize: {
appleId: process.env.APPLE_ID,
appleIdPassword: process.env.APPLE_PASSWORD,
teamId: process.env.APPLE_TEAM_ID
},
// NOTE: Electron Forge ignores packagerConfig.dir and always packages from the
// config file's directory. We use packageAfterCopy hook instead to customize output.
// dir: path.join(__dirname, '.package'), // Not supported by Forge
// Since we bundle everything with esbuild, we don't need node_modules at all. // Since we bundle everything with esbuild, we don't need node_modules at all.
// These settings prevent Forge's dependency walker (flora-colossus) from trying // These settings prevent Forge's dependency walker (flora-colossus) from trying
// to analyze/copy node_modules, which fails with pnpm's symlinked workspaces. // to analyze/copy node_modules, which fails with pnpm's symlinked workspaces.
@ -141,113 +150,82 @@ module.exports = {
console.log('✅ All assets staged in .package/'); console.log('✅ All assets staged in .package/');
}, },
// Hook signature: async (config, buildPath, electronVersion, platform, arch)
// Hook runs after Forge copies source to output directory // Called after Forge copies source directory to build output
// We use this to replace the unbundled code with our bundled version // This is where we replace source files with bundled/staged files
// Hook signature: (forgeConfig, buildPath, electronVersion, platform, arch) packageAfterCopy: async (config, buildPath, electronVersion, platform, arch) => {
packageAfterCopy: async (forgeConfig, buildPath, electronVersion, platform, arch) => {
const fs = require('fs'); const fs = require('fs');
const packageDir = path.join(__dirname, '.package'); const packageDir = path.join(__dirname, '.package');
// buildPath already points to the app directory (Contents/Resources/app)
const appResourcesPath = buildPath;
// buildPath is the app directory inside the packaged output console.log('📦 Copying staged files from .package/ to packaged app...');
// e.g., out/Rowboat-darwin-arm64/Rowboat.app/Contents/Resources/app
// App bundle root is 3 levels up: buildPath/../../.. = Rowboat.app
const appBundleRoot = path.resolve(buildPath, '../../..');
console.log('Fixing packaged app at:', buildPath);
console.log('App bundle root:', appBundleRoot);
// Remove unbundled dist/ directory (source TypeScript output)
// 1. Remove the unbundled dist/ directory (it has imports to @x/core, @x/shared) const unbundledDist = path.join(appResourcesPath, 'dist');
const distDir = path.join(buildPath, 'dist'); if (fs.existsSync(unbundledDist)) {
if (fs.existsSync(distDir)) {
console.log('Removing unbundled dist/...'); console.log('Removing unbundled dist/...');
fs.rmSync(distDir, { recursive: true }); fs.rmSync(unbundledDist, { recursive: true });
} }
// 2. Copy the bundled dist-bundle/ from staging // Copy bundled dist-bundle/ from staging
console.log('Copying bundled dist-bundle/...'); const distBundleSrc = path.join(packageDir, 'dist-bundle');
const bundleSrc = path.join(packageDir, 'dist-bundle'); const distBundleDest = path.join(appResourcesPath, 'dist-bundle');
const bundleDest = path.join(buildPath, 'dist-bundle'); if (fs.existsSync(distBundleSrc)) {
fs.cpSync(bundleSrc, bundleDest, { recursive: true }); console.log('Copying dist-bundle/...');
fs.mkdirSync(distBundleDest, { recursive: true });
fs.cpSync(distBundleSrc, distBundleDest, { recursive: true });
}
// 3. Copy preload from staging // Copy preload/ from staging
console.log('Copying preload/...');
const preloadSrc = path.join(packageDir, 'preload'); const preloadSrc = path.join(packageDir, 'preload');
const preloadDest = path.join(buildPath, 'preload'); const preloadDest = path.join(appResourcesPath, 'preload');
if (fs.existsSync(preloadSrc)) {
console.log('Copying preload/...');
// Remove old preload if it exists
if (fs.existsSync(preloadDest)) {
fs.rmSync(preloadDest, { recursive: true });
}
fs.cpSync(preloadSrc, preloadDest, { recursive: true }); fs.cpSync(preloadSrc, preloadDest, { recursive: true });
}
// 4. Copy renderer from staging // Copy renderer/ from staging
console.log('Copying renderer/...');
const rendererSrc = path.join(packageDir, 'renderer'); const rendererSrc = path.join(packageDir, 'renderer');
const rendererDest = path.join(buildPath, 'renderer'); const rendererDest = path.join(appResourcesPath, 'renderer');
if (fs.existsSync(rendererSrc)) {
console.log('Copying renderer/...');
// Remove old renderer if it exists
if (fs.existsSync(rendererDest)) {
fs.rmSync(rendererDest, { recursive: true });
}
fs.cpSync(rendererSrc, rendererDest, { recursive: true }); fs.cpSync(rendererSrc, rendererDest, { recursive: true });
}
// 5. Update package.json to point to bundled entry // Update package.json to point to bundled entry point
const packageJsonPath = path.join(appResourcesPath, 'package.json');
if (fs.existsSync(packageJsonPath)) {
console.log('Updating package.json...'); console.log('Updating package.json...');
const packageJsonPath = path.join(buildPath, 'package.json'); const packageJson = {
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')); name: '@x/main',
packageJson.main = 'dist-bundle/main.js'; version: '0.1.0',
// Remove workspace dependencies (they're bundled now) main: 'dist-bundle/main.js',
delete packageJson.dependencies; // Note: No "type": "module" since we bundle as CommonJS
delete packageJson.devDependencies; // No dependencies/devDependencies since everything is bundled
delete packageJson.scripts; };
// Remove "type": "module" - we bundle as CommonJS for compatibility
// with dependencies that use dynamic require()
delete packageJson.type;
fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2)); fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2));
}
// 6. Clean up source files that shouldn't be in production // Clean up source files that shouldn't be in packaged app
const filesToRemove = ['tsconfig.json', 'forge.config.cjs', 'agents.md']; const filesToRemove = ['src', 'tsconfig.json', 'forge.config.cjs', 'agents.md', '.gitignore', 'bundle.mjs'];
for (const file of filesToRemove) { for (const file of filesToRemove) {
const filePath = path.join(buildPath, file); const filePath = path.join(appResourcesPath, file);
if (fs.existsSync(filePath)) { if (fs.existsSync(filePath)) {
fs.rmSync(filePath); console.log(`Removing ${file}...`);
fs.rmSync(filePath, { recursive: true, force: true });
} }
} }
const srcDir = path.join(buildPath, 'src');
if (fs.existsSync(srcDir)) {
fs.rmSync(srcDir, { recursive: true });
}
// 7. Remove any signature metadata AFTER all file modifications console.log('✅ Staged files copied to packaged app');
// This prevents "code has no resources but signature indicates they must be present" error
// which occurs when _CodeSignature exists but files it references have been moved/modified
const codeSignatureDir = path.join(appBundleRoot, 'Contents', '_CodeSignature');
if (fs.existsSync(codeSignatureDir)) {
console.log('Removing _CodeSignature directory (files were modified after packaging)...');
fs.rmSync(codeSignatureDir, { recursive: true });
}
// 8. Re-sign the app bundle with adhoc signature after file modifications
// The original bundle signature references resources that we've moved/modified
// Re-signing with adhoc signature creates a new signature that matches the current bundle structure
// This prevents "code has no resources but signature indicates they must be present" error
try {
const { execSync } = require('child_process');
// Re-sign the entire app bundle with an adhoc signature
// This creates a fresh signature that matches the modified bundle structure
execSync(`codesign --force --deep --sign - "${appBundleRoot}"`, { stdio: 'ignore' });
console.log('Re-signed app bundle with adhoc signature (matches modified structure)');
} catch (e) {
// Ignore errors - codesign might fail, but app should still work
console.log('Warning: Failed to re-sign app bundle:', e.message);
}
// 9. Clear any signature-related extended attributes
// Even without _CodeSignature or embedded signatures, extended attributes can contain invalid signature metadata
// This prevents "code has no resources but signature indicates they must be present" error
try {
const { execSync } = require('child_process');
// Clear extended attributes from the entire app bundle
// This removes any signature metadata that might be stored in xattrs
execSync(`xattr -cr "${appBundleRoot}"`, { stdio: 'ignore' });
console.log('Cleared extended attributes from app bundle');
} catch (e) {
// Ignore errors - xattr might not be available or might fail silently
}
console.log('✅ Packaged app fixed with bundled code');
} }
} }
}; };

View file

@ -3,6 +3,7 @@ import path from "node:path";
import { setupIpcHandlers, startRunsWatcher, startWorkspaceWatcher, stopWorkspaceWatcher } from "./ipc.js"; import { setupIpcHandlers, startRunsWatcher, startWorkspaceWatcher, stopWorkspaceWatcher } from "./ipc.js";
import { fileURLToPath, pathToFileURL } from "node:url"; import { fileURLToPath, pathToFileURL } from "node:url";
import { dirname } from "node:path"; import { dirname } from "node:path";
import { existsSync } from "node:fs";
import { init as initGmailSync } from "@x/core/dist/knowledge/sync_gmail.js"; import { init as initGmailSync } from "@x/core/dist/knowledge/sync_gmail.js";
import { init as initCalendarSync } from "@x/core/dist/knowledge/sync_calendar.js"; import { init as initCalendarSync } from "@x/core/dist/knowledge/sync_calendar.js";
import { init as initFirefliesSync } from "@x/core/dist/knowledge/sync_fireflies.js"; import { init as initFirefliesSync } from "@x/core/dist/knowledge/sync_fireflies.js";
@ -13,6 +14,10 @@ import { init as initPreBuiltRunner } from "@x/core/dist/pre_built/runner.js";
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename); const __dirname = dirname(__filename);
// #region agent log
fetch('http://127.0.0.1:7242/ingest/dd33b297-24f6-4846-82f9-02599308a13a',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'main.ts:14',message:'__dirname resolved',data:{__dirname,__filename,isPackaged:app.isPackaged},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'B'})}).catch(()=>{});
// #endregion
// Path resolution differs between development and production: // Path resolution differs between development and production:
// - Development: main.js runs from dist/, preload is at ../../preload/dist/ (sibling dir) // - Development: main.js runs from dist/, preload is at ../../preload/dist/ (sibling dir)
// - Production: main.js runs from .package/dist-bundle/, preload is at ../preload/dist/ (copied into .package/) // - Production: main.js runs from .package/dist-bundle/, preload is at ../preload/dist/ (copied into .package/)
@ -21,9 +26,17 @@ const preloadPath = app.isPackaged
: path.join(__dirname, "../../preload/dist/preload.js"); // Development : path.join(__dirname, "../../preload/dist/preload.js"); // Development
console.log("preloadPath", preloadPath); console.log("preloadPath", preloadPath);
// #region agent log
fetch('http://127.0.0.1:7242/ingest/dd33b297-24f6-4846-82f9-02599308a13a',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'main.ts:22',message:'preloadPath computed',data:{preloadPath,exists:existsSync(preloadPath)},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'B'})}).catch(()=>{});
// #endregion
// Register custom protocol for serving built renderer files in production // Register custom protocol for serving built renderer files in production
function registerAppProtocol() { function registerAppProtocol() {
protocol.handle('app', (request) => { protocol.handle('app', (request) => {
// #region agent log
fetch('http://127.0.0.1:7242/ingest/dd33b297-24f6-4846-82f9-02599308a13a',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'main.ts:26',message:'protocol handler called',data:{url:request.url},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'C'})}).catch(()=>{});
// #endregion
// Remove 'app://' prefix and get the path // Remove 'app://' prefix and get the path
let urlPath = request.url.slice('app://'.length); let urlPath = request.url.slice('app://'.length);
@ -45,6 +58,10 @@ function registerAppProtocol() {
: path.join(__dirname, '../../renderer/dist'); : path.join(__dirname, '../../renderer/dist');
const filePath = path.join(rendererDistPath, urlPath); const filePath = path.join(rendererDistPath, urlPath);
// #region agent log
fetch('http://127.0.0.1:7242/ingest/dd33b297-24f6-4846-82f9-02599308a13a',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'main.ts:46',message:'renderer path resolution',data:{rendererDistPath,filePath,urlPath,exists:existsSync(filePath),rendererDistExists:existsSync(rendererDistPath)},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'D'})}).catch(()=>{});
// #endregion
return net.fetch(pathToFileURL(filePath).toString()); return net.fetch(pathToFileURL(filePath).toString());
}); });
} }
@ -62,9 +79,23 @@ function createWindow() {
}, },
}); });
// #region agent log
const loadURL = app.isPackaged ? 'app://./' : 'http://localhost:5173';
fetch('http://127.0.0.1:7242/ingest/dd33b297-24f6-4846-82f9-02599308a13a',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'main.ts:65',message:'createWindow called',data:{isPackaged:app.isPackaged,loadURL,preloadPath},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'E'})}).catch(()=>{});
// #endregion
if (app.isPackaged) { if (app.isPackaged) {
// Production: load from custom protocol (serves built renderer files) // Production: load from custom protocol (serves built renderer files)
win.loadURL('app://./'); win.loadURL('app://./');
// #region agent log
win.webContents.on('did-fail-load', (event, errorCode, errorDescription, validatedURL) => {
fetch('http://127.0.0.1:7242/ingest/dd33b297-24f6-4846-82f9-02599308a13a',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'main.ts:69',message:'window load failed',data:{errorCode,errorDescription,validatedURL},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'C'})}).catch(()=>{});
});
win.webContents.on('did-finish-load', () => {
fetch('http://127.0.0.1:7242/ingest/dd33b297-24f6-4846-82f9-02599308a13a',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'main.ts:72',message:'window load finished',data:{url:win.webContents.getURL()},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'C'})}).catch(()=>{});
});
// #endregion
} else { } else {
// Development: load from Vite dev server // Development: load from Vite dev server
win.loadURL('http://localhost:5173'); win.loadURL('http://localhost:5173');
@ -72,9 +103,17 @@ function createWindow() {
} }
app.whenReady().then(() => { app.whenReady().then(() => {
// #region agent log
fetch('http://127.0.0.1:7242/ingest/dd33b297-24f6-4846-82f9-02599308a13a',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'main.ts:74',message:'app.whenReady triggered',data:{isPackaged:app.isPackaged},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'A'})}).catch(()=>{});
// #endregion
// Register custom protocol before creating window (for production builds) // Register custom protocol before creating window (for production builds)
registerAppProtocol(); registerAppProtocol();
// #region agent log
fetch('http://127.0.0.1:7242/ingest/dd33b297-24f6-4846-82f9-02599308a13a',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'main.ts:77',message:'protocol registered',data:{isPackaged:app.isPackaged},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'C'})}).catch(()=>{});
// #endregion
setupIpcHandlers(); setupIpcHandlers();
createWindow(); createWindow();