Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions docs/packaging/signing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# Code Signing & Notarization

Production desktop apps need signatures so the OS won't flag them as
"from an unidentified developer". Bunlet wires the tools — `codesign`
+ `notarytool` on macOS, `signtool` on Windows, `gpg --detach-sign` on
Linux — but credentials are yours to supply.

## macOS — codesign

The packager's `signDarwinApp()` step calls `codesign --deep --sign
"$identity"` with optional `--options runtime` and `--entitlements`.

Required: an Apple Developer ID Application certificate installed in
your login keychain. Find its identity with:

```bash
security find-identity -v -p codesigning
```

Pass the identity to the packager via `signOptions.identity` or set it
in your bunlet config. The default packager run does not sign — you have
to opt in.

## macOS — notarization

After signing, Apple still requires notarization for Gatekeeper to
trust the artifact on first run. Use `notarizeDarwinApp()` (exported
from `@bunlet/cli`) — it wraps `xcrun notarytool submit … --wait` and
runs `xcrun stapler staple` on success.

Credentials (read from options first, then env vars):

| Argument | Env var | Source |
|---------------------------|-------------------------------|--------|
| `appleId` | `APPLE_ID` | Apple Developer account email |
| `teamId` | `APPLE_TEAM_ID` | https://developer.apple.com/account → Membership |
| `appSpecificPassword` | `APPLE_APP_SPECIFIC_PASSWORD` | https://appleid.apple.com → Sign-In and Security → App-Specific Passwords |

App-specific passwords are required because Apple ID 2FA blocks regular
passwords for automation. Generate one labelled e.g. "bunlet notarize".

Minimum reproducible run:

```bash
APPLE_ID="you@example.com" \
APPLE_TEAM_ID="ABCDEFGHIJ" \
APPLE_APP_SPECIFIC_PASSWORD="abcd-efgh-ijkl-mnop" \
bun -e "
import { notarizeDarwinApp } from '@bunlet/cli';
await notarizeDarwinApp('release/MyApp-1.0.0.dmg');
"
```

The wrapper throws a structured error listing each missing credential by
name — it never silently skips notarization.

CI: do not check credentials into the repo. Inject them as GitHub
Actions secrets (`APPLE_*`) and pass through to the notarize step only
on release tag pushes. We do not run notarization in this repo's CI
because the project does not have an Apple Developer account.

## Windows — signtool

The packager's `signExe()` wraps `signtool sign /f <cert.pfx> /p
<password> /tr <timestamp-server>`. You need an Authenticode code
signing certificate (`.pfx`) and a timestamp server URL (use
`http://timestamp.digicert.com` for DigiCert-issued certs).

Pass via `signOptions`:

- `certificateFile`: path to the `.pfx`
- `certificatePassword`: password to unlock it
- `timestampServer`: timestamp URL

EV (Extended Validation) certificates avoid Microsoft SmartScreen warmup
delays for new apps but require a hardware token; the packager handles
both because `signtool` itself does.

## Linux — gpg detached signatures

AppImage is the recommended distribution format; it carries no
signature on its own but expects a sidecar `.AppImage.sig`. Use
`signAppImage()`:

```bash
bun -e "
import { signAppImage } from '@bunlet/cli';
signAppImage('release/MyApp-1.0.0.AppImage', { gpgKeyId: 'ABC123DEF456' });
"
```

Produces `release/MyApp-1.0.0.AppImage.sig`. Distribute both. Verifiers
run:

```bash
gpg --verify MyApp.AppImage.sig MyApp.AppImage
```

Required: a GPG key in your keyring. `gpg --list-secret-keys --keyid-format=long`
gives the id. If `gpg-agent` doesn't already cache the passphrase, pass
`passphrase` in the options (loopback pinentry).

## What's NOT done in v1.0

- Automated notarization run in CI (needs Apple Developer account).
- `.deb` package signing via `dpkg-sig`.
- Microsoft Store / WinGet bundle signing.
- Sparkle/Squirrel-style update signature verification (the auto-updater
verifies SHA-512 against the manifest, but does not yet check a
digital signature on the manifest itself).

These are tracked for v1.1.
18 changes: 18 additions & 0 deletions packages/bunlet-cli/src/build/packager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,24 @@ export interface SignOptions {
timestampServer?: string;
}

export interface NotarizeOptions {
/** Apple ID email. Falls back to APPLE_ID env. */
appleId?: string;
/** Apple Developer team ID. Falls back to APPLE_TEAM_ID env. */
teamId?: string;
/** App-specific password. Falls back to APPLE_APP_SPECIFIC_PASSWORD env. */
appSpecificPassword?: string;
/** If true, run `xcrun stapler staple` after submission succeeds. Default true. */
staple?: boolean;
}

export interface LinuxSignOptions {
/** GPG key id (long form or fingerprint) used for `gpg --local-user`. */
gpgKeyId: string;
/** Optional passphrase. If not provided, the user's gpg-agent must supply one. */
passphrase?: string;
}

export interface PackagerContext {
name: string;
version: string;
Expand Down
59 changes: 58 additions & 1 deletion packages/bunlet-cli/src/build/platforms/darwin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import * as path from 'path';
import { execSync } from 'child_process';
import { generateInfoPlist, type AppManifest } from '../manifest';
import { generateIconsForPlatform } from '../icons';
import type { SignOptions } from '../packager';
import type { SignOptions, NotarizeOptions } from '../packager';

export interface DarwinBuildOptions {
name: string;
Expand Down Expand Up @@ -275,6 +275,63 @@ async function signApp(
execSync(codesignArgs.join(' '), { stdio: 'pipe' });
}

/**
* Notarize a signed .dmg or .app via Apple's notary service.
*
* Reads credentials from `options` first, falls back to env vars
* (`APPLE_ID`, `APPLE_TEAM_ID`, `APPLE_APP_SPECIFIC_PASSWORD`). Throws a
* descriptive error when any required credential is missing — does NOT
* silently skip. On success, by default also runs `xcrun stapler staple`
* so the notarization ticket is bundled with the artifact for offline
* Gatekeeper validation.
*
* Requires Xcode 13+ (for `notarytool`). The wrapper does not run real
* notarization in CI in this PR — it gives a working entry point for
* release pipelines that have Apple Developer credentials.
*/
export async function notarizeDarwinApp(
artifactPath: string,
options: NotarizeOptions = {}
): Promise<void> {
const appleId = options.appleId ?? process.env.APPLE_ID;
const teamId = options.teamId ?? process.env.APPLE_TEAM_ID;
const password = options.appSpecificPassword ?? process.env.APPLE_APP_SPECIFIC_PASSWORD;

const missing: string[] = [];
if (!appleId) missing.push('APPLE_ID');
if (!teamId) missing.push('APPLE_TEAM_ID');
if (!password) missing.push('APPLE_APP_SPECIFIC_PASSWORD');
if (missing.length > 0) {
throw new Error(
`[bunlet] notarizeDarwinApp: missing credential(s): ${missing.join(', ')}. ` +
`Set the env vars or pass them in NotarizeOptions. ` +
`See docs/packaging/signing.md.`
);
}
if (!fs.existsSync(artifactPath)) {
throw new Error(`[bunlet] notarizeDarwinApp: artifact does not exist: ${artifactPath}`);
}

const args = [
'xcrun',
'notarytool',
'submit',
`"${artifactPath}"`,
'--apple-id',
`"${appleId}"`,
'--team-id',
`"${teamId}"`,
'--password',
`"${password}"`,
'--wait',
];
execSync(args.join(' '), { stdio: 'inherit' });

if (options.staple !== false) {
execSync(`xcrun stapler staple "${artifactPath}"`, { stdio: 'inherit' });
}
}

/**
* Recursively copy a directory
*/
Expand Down
49 changes: 49 additions & 0 deletions packages/bunlet-cli/src/build/platforms/linux.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import * as path from 'path';
import { execSync } from 'child_process';
import { generateDesktopFile, generateAppRun, type AppManifest } from '../manifest';
import { generateIconsForPlatform } from '../icons';
import type { LinuxSignOptions } from '../packager';

export interface LinuxBuildOptions {
name: string;
Expand Down Expand Up @@ -272,6 +273,54 @@ exec bun run "/opt/${appName}/main.js" "\$@"
}
}

/**
* Sign an AppImage with a detached GPG signature. Produces
* `<path>.AppImage.sig` alongside the original. Requires `gpg` on PATH
* and a configured signing key. Throws a descriptive error when `gpg`
* is missing or the key id is empty — does NOT silently skip.
*
* Usage:
* await signAppImage('release/MyApp.AppImage', { gpgKeyId: 'ABC123' })
*/
export function signAppImage(appImagePath: string, options: LinuxSignOptions): void {
if (!options.gpgKeyId) {
throw new Error('[bunlet] signAppImage: gpgKeyId is required');
}
if (!fs.existsSync(appImagePath)) {
throw new Error(`[bunlet] signAppImage: file not found: ${appImagePath}`);
}
// Verify gpg is reachable before invoking — gives a clearer error than
// a non-zero exit from the shell.
try {
execSync('gpg --version', { stdio: 'ignore' });
} catch {
throw new Error('[bunlet] signAppImage: `gpg` not found on PATH. Install GnuPG and configure a signing key.');
}

const sigPath = `${appImagePath}.sig`;
if (fs.existsSync(sigPath)) {
fs.rmSync(sigPath);
}

const args = [
'gpg',
'--batch',
'--yes',
'--detach-sign',
'--armor',
'--local-user',
`"${options.gpgKeyId}"`,
'--output',
`"${sigPath}"`,
];
if (options.passphrase) {
args.splice(1, 0, '--pinentry-mode', 'loopback', '--passphrase', `"${options.passphrase}"`);
}
args.push(`"${appImagePath}"`);

execSync(args.join(' '), { stdio: 'inherit' });
}

/**
* Build Linux package (AppImage or deb)
*/
Expand Down
83 changes: 83 additions & 0 deletions packages/bunlet-cli/src/build/platforms/signing.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/**
* Unit tests for the signing/notarize wrapper error contracts. These
* don't perform real signing — they verify that the wrappers fail with a
* clean message when credentials are missing or inputs are wrong, and
* succeed only when given everything they need.
*/

import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';

import { notarizeDarwinApp } from './darwin';
import { signAppImage } from './linux';

let tmp: string;
let savedEnv: Record<string, string | undefined>;

describe('notarizeDarwinApp', () => {
beforeEach(() => {
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'bunlet-notarize-'));
savedEnv = {
APPLE_ID: process.env.APPLE_ID,
APPLE_TEAM_ID: process.env.APPLE_TEAM_ID,
APPLE_APP_SPECIFIC_PASSWORD: process.env.APPLE_APP_SPECIFIC_PASSWORD,
};
delete process.env.APPLE_ID;
delete process.env.APPLE_TEAM_ID;
delete process.env.APPLE_APP_SPECIFIC_PASSWORD;
});

afterEach(() => {
for (const [k, v] of Object.entries(savedEnv)) {
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
try {
fs.rmSync(tmp, { recursive: true, force: true });
} catch {}
});

test('rejects when all credentials are missing', async () => {
const fake = path.join(tmp, 'fake.dmg');
fs.writeFileSync(fake, 'x');
await expect(notarizeDarwinApp(fake)).rejects.toThrow(/APPLE_ID/);
});

test('rejects when artifact does not exist', async () => {
process.env.APPLE_ID = 'a@b.c';
process.env.APPLE_TEAM_ID = 'TEAMID';
process.env.APPLE_APP_SPECIFIC_PASSWORD = 'abcd-efgh-ijkl-mnop';
await expect(notarizeDarwinApp('/no/such/path.dmg')).rejects.toThrow(/does not exist/);
});

test('error message lists each missing credential by name', async () => {
process.env.APPLE_ID = 'a@b.c';
const fake = path.join(tmp, 'fake.dmg');
fs.writeFileSync(fake, 'x');
await expect(notarizeDarwinApp(fake)).rejects.toThrow(/APPLE_TEAM_ID.*APPLE_APP_SPECIFIC_PASSWORD/);
});
});

describe('signAppImage', () => {
beforeEach(() => {
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'bunlet-gpg-'));
});

afterEach(() => {
try {
fs.rmSync(tmp, { recursive: true, force: true });
} catch {}
});

test('rejects when gpgKeyId is empty', () => {
const fake = path.join(tmp, 'app.AppImage');
fs.writeFileSync(fake, 'x');
expect(() => signAppImage(fake, { gpgKeyId: '' })).toThrow(/gpgKeyId is required/);
});

test('rejects when file is missing', () => {
expect(() => signAppImage('/no/such/path.AppImage', { gpgKeyId: 'ABC' })).toThrow(/not found/);
});
});
Loading
Loading