Murasaki
Building & Distribution

Distribution

Shipping real native installers — cross-arch builds, signing, notarization, and CI.

murasaki bundle / murasaki installer ship real installers on macOS (.app / .dmg), Windows (a portable .zip, an NSIS .exe, and an MSI .msi), and Linux (an AppDir + .AppImage, and a .deb), cross-arch (arm64 / x64) on all three. The Linux .AppImage runs and self-updates like the macOS/Windows payloads do; see Linux: AppImage and .deb below for its update story, the .deb's system-package-manager caveat, and the FUSE note for running an AppImage in a container/CI.

TargetBundleInstallerPublic code signing
macOS arm64 / x64.app + .app.zip.dmgDeveloper ID + notarization built in
Windows x64 / arm64portable folder + .zipNSIS .exe; WiX .msi on WindowsAuthenticode via SignTool built in
Linux x64 / arm64AppDir + .AppImage.debGPG detached signatures via --sign; no distro-repo/keyring trust

A generated file is not automatically distribution-ready. Test the installed artifact on a clean machine, validate its signature, and verify every production dependency/resource. Murasaki is pre-1.0 and its Node dependency packaging does not cover every dynamic native addon or runtime-discovered asset layout yet.

Cross-arch builds

The bundle carries a portable, target-specific Node runtime (downloaded from nodejs.org and cached under ~/.murasaki/node/) plus the compiled native launcher binary — not whatever node happens to be running the CLI. Murasaki verifies Node's clear-signed SHASUMS256.txt.asc against pinned official Node release-key fingerprints before trusting the selected archive checksum. That means an Apple Silicon dev machine can also produce an Intel build, and vice versa:

Runtime downloads are HTTPS-only, timeout- and size-bounded, streamed to a private temporary file, signature- and checksum-verified before caching. Murasaki re-hashes a cached Node or AppImage runtime before every use; a partial or modified cache entry is discarded rather than packaged.

murasaki bundle --arch x64             # x64 .app on an Apple Silicon Mac
murasaki bundle --arch arm64           # arm64 .app on an Intel Mac
murasaki bundle --target win32-arm64   # an arm64 Windows bundle

murasaki installer forwards --arch / --target to bundle the same way. Windows targets are win32-x64 / win32-arm64.

URL schemes and file associations

Declare packaged-app handlers in murasaki.config.ts:

murasaki.config.ts
import { defineConfig } from 'murasaki'

export default defineConfig({
  appId: 'com.example.notes',
  productName: 'Notes',
  protocols: [{ scheme: 'example-notes', name: 'Notes link' }],
  fileAssociations: [{
    extensions: ['enote'],
    name: 'Notes document',
    role: 'editor',
    mimeType: 'application/x-example-note',
  }],
})

The packaging result differs by target:

ArtifactRegistration behavior
macOS .app / .dmgCFBundleURLTypes, CFBundleDocumentTypes, and exported document UTIs are written to the app's Info.plist before signing. The DMG contains that app unchanged.
Windows NSIS .exeRegisters protocols, ProgIDs, Open With entries, and Default Apps capabilities per-user by default, or per-machine with installMode: 'perMachine'.
Windows WiX .msiRegisters the same handlers per-machine.
Windows portable folder / .zipDoes not modify the registry or register itself automatically.
Linux AppDir / .AppImage / .debMimeType= lines (x-scheme-handler/<scheme> per protocol, application/x-<extension> per file-association extension) are written into the .desktop file. The .deb installs it under usr/share/applications/ and refreshes the desktop database on install/remove; a manually-extracted AppDir/.AppImage has no equivalent OS-level registration step. Cold-start argv (the %U/%F the .desktop Exec= line expands to) and second-instance activation both work either way — see below.

Matching cold-start, second-instance, and macOS open events are delivered to Node Main's openRequested() hook after ready(). The Windows portable build can still receive a registered-looking URL or file when it is passed directly on its command line, but installation-free artifacts intentionally do not claim operating-system defaults.

Windows registration makes the app an available handler; it does not overwrite the user's protected default-app choice. Test both a fresh install and an upgrade, and verify that uninstall removes only your app's handler entries.

Signing & notarization

Murasaki owns the signing orchestration, not the publisher identity. Supply your Apple Developer ID or Windows certificate/Artifact Signing profile; --sign deliberately fails instead of silently publishing unsigned output.

By default, murasaki bundle produces an ad-hoc signed .app, and murasaki installer puts that app in a .dmg. This lets macOS verify local bundle integrity, but it does not identify a trusted developer and the result is not notarizable. For your own downloaded development artifact, you can remove quarantine with:

xattr -dr com.apple.quarantine "<path>"

Do not make that command the installation path for end users. Public, warning-free distribution requires Developer ID signing and notarization.

For warning-free distribution, sign and notarize with your own Apple Developer ID — Murasaki ships no certificate of its own:

murasaki bundle --sign                 # Developer ID-sign the .app
murasaki installer --sign --notarize   # + submit the .dmg to Apple, staple the ticket
  • --sign signs the .app with a hardened runtime (Apple's documented flow: inner code first, then the outer bundle). The signing identity resolves from $MURASAKI_SIGN_IDENTITY, then config.sign.identity, then the first "Developer ID Application" identity in your keychain. Murasaki signs the main app and bundled Node helper with separate entitlement sets: host/system rights stay on the app, while only Node receives JIT / unsigned- executable-memory / no-library-validation. App Sandbox is not currently supported; sign.appSandbox: true is rejected rather than emitting an invalid inherited-helper signature. Override the hardened-runtime sets independently with config.sign.entitlements and config.sign.helperEntitlements; missing or invalid files fail closed.
  • --notarize requires --sign (notarization only accepts Developer ID-signed code) and reads credentials from APPLE_ID, APPLE_TEAM_ID, and APPLE_APP_PASSWORD (an app-specific password) — never from config or a file. It submits the .dmg to Apple's notary service, waits for the result, then staples the ticket so Gatekeeper can verify it offline.

Both require a paid Apple Developer Program membership.

Validate the macOS artifact

Run these against the final .app after signing and the final .dmg after notarization:

codesign --verify --strict --verbose=2 "dist/bundle/My App.app"
codesign -dvvv --entitlements :- "dist/bundle/My App.app"
spctl -a -t open --context context:primary-signature -vv "dist/My App-1.0.0.dmg"
xcrun stapler validate "dist/My App-1.0.0.dmg"

codesign --verify checks the app's code/resource seal. spctl evaluates the distributed DMG against Gatekeeper policy. stapler validate checks that its notarization ticket is attached. These answer different questions; run all of them for a public release.

Windows installers and signing

murasaki bundle --target win32-x64 produces a portable folder and .zip. murasaki installer additionally invokes tools already available on the build host:

  • makensis produces the NSIS .exe and may run on macOS or Windows.
  • WiX v4 produces the .msi and runs on Windows.

For a hermetic CI toolchain, set MURASAKI_NSIS_PATH and/or MURASAKI_WIX_PATH to the exact executable. An explicitly configured path is authoritative: if it is missing or cannot run, that installer type is skipped and the command fails when no other installer can be produced.

The NSIS installer can be perUser (the default, no elevation) or perMachine; MSI is always per-machine. See Configuration for branding and upgrade identity.

Run the signed release on Windows (cross-building unsigned Windows artifacts still works elsewhere):

pnpm exec murasaki bundle --target win32-x64 --sign
pnpm exec murasaki installer --target win32-x64 --sign

The bundle command signs <productName>.exe before creating the portable ZIP. The installer command reuses that signed payload, then signs the generated NSIS setup and MSI. Every signing operation uses SHA-256, an RFC 3161 timestamp by default, and a separate signtool verify /pa /v /tw pass. A signing or verification failure stops the release.

An unsigned installer is useful for local packaging checks, but current Windows application-control policy can block it even when the files are otherwise well formed. Murasaki warns in that case. Sign public artifacts; do not ask users to disable SmartScreen or Smart App Control.

Choose one signer. Config values can be overridden in CI without editing the checkout:

SignerConfigEnvironment override
PFX/P12sign.windows.certificateFileMURASAKI_WINDOWS_CERTIFICATE_FILE + optional MURASAKI_WINDOWS_CERTIFICATE_PASSWORD
Imported certificate by subjectsign.windows.certificateSubjectNameMURASAKI_WINDOWS_CERTIFICATE_SUBJECT
Imported certificate by thumbprintsign.windows.certificateSha1MURASAKI_WINDOWS_CERTIFICATE_SHA1
Microsoft Artifact Signingsign.windows.artifactSigning.{dlib,metadata}MURASAKI_WINDOWS_ARTIFACT_SIGNING_DLIB + MURASAKI_WINDOWS_ARTIFACT_SIGNING_METADATA

With none of those selectors, SignTool /a chooses the best code-signing certificate from CurrentUser/My. Set certificateStore: 'localMachine' for the machine store. Override the SignTool executable with MURASAKI_SIGNTOOL_PATH, and the timestamp service with MURASAKI_WINDOWS_TIMESTAMP_URL (false disables it, which is not recommended for public releases).

For Microsoft Artifact Signing, dlib points to Azure.CodeSigning.Dlib.dll and metadata to the non-secret account/profile JSON. Authentication stays in Azure CLI, workload identity, or managed identity; do not put credentials in that JSON or Murasaki config. Murasaki uses Microsoft's Artifact Signing timestamp authority by default for this provider.

Do not treat the updater's Ed25519 manifest signature as an Authenticode replacement: it protects Murasaki's update channel, not Windows SmartScreen's publisher identity. A correctly signed new publisher can still see SmartScreen prompts while reputation develops.

Windows signed releases with GitHub Actions

This minimal PFX example writes the certificate only into the runner temp directory. Prefer an imported store certificate or cloud/HSM provider when that matches your certificate policy:

.github/workflows/release-windows.yml
jobs:
  windows:
    runs-on: windows-2025
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 24
      - run: pnpm install --frozen-lockfile
      - name: Materialize signing certificate
        shell: pwsh
        env:
          CERTIFICATE_BASE64: ${{ secrets.WINDOWS_CERTIFICATE_PFX }}
        run: |
          [IO.File]::WriteAllBytes(
            "$env:RUNNER_TEMP\release.pfx",
            [Convert]::FromBase64String($env:CERTIFICATE_BASE64)
          )
      - name: Build signed installers
        env:
          MURASAKI_WINDOWS_CERTIFICATE_FILE: ${{ runner.temp }}\release.pfx
          MURASAKI_WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
        run: pnpm exec murasaki installer --target win32-x64 --sign

macOS signed releases with GitHub Actions

Build + (optionally) sign + notarize a .dmg on tag push and attach it to a GitHub Release. Add this as .github/workflows/release.yml in your app:

.github/workflows/release.yml
name: Release
on:
  push:
    tags: ['v*']
jobs:
  release:
    runs-on: macos-14
    permissions:
      contents: write
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 24
      - run: pnpm install
      - name: Import signing certificate
        if: ${{ secrets.APPLE_CERTIFICATE_P12 != '' }}
        env:
          CERT_P12: ${{ secrets.APPLE_CERTIFICATE_P12 }}
          CERT_PW: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
        run: |
          KC="$RUNNER_TEMP/app.keychain-db"
          security create-keychain -p "" "$KC"
          security set-keychain-settings -lut 21600 "$KC"
          security unlock-keychain -p "" "$KC"
          echo "$CERT_P12" | base64 --decode > "$RUNNER_TEMP/cert.p12"
          security import "$RUNNER_TEMP/cert.p12" -k "$KC" -P "$CERT_PW" -T /usr/bin/codesign
          security set-key-partition-list -S apple-tool:,apple: -s -k "" "$KC"
          security list-keychains -d user -s "$KC" $(security list-keychains -d user | tr -d '"')
      - name: Build installer
        env:
          APPLE_ID: ${{ secrets.APPLE_ID }}
          APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
          APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }}
          HAS_CERT: ${{ secrets.APPLE_CERTIFICATE_P12 != '' }}
        run: |
          if [ "$HAS_CERT" = "true" ]; then
            pnpm exec murasaki installer --sign --notarize
          else
            pnpm exec murasaki installer
          fi
      - uses: softprops/action-gh-release@v2
        with:
          files: dist/*.dmg

Add these repository secrets to sign + notarize (omit them all for an unsigned .dmg): APPLE_CERTIFICATE_P12 (base64 of your Developer ID .p12), APPLE_CERTIFICATE_PASSWORD, APPLE_ID, APPLE_TEAM_ID, APPLE_APP_PASSWORD.

Never commit these secrets to config or source — --notarize deliberately only reads them from the environment.

Linux: AppImage and .deb

The native launcher runs the produced AppDir/.AppImage/.deb: window, webview, single-instance locking, deep links, graceful shutdown, and crash reporting all work the same way they do on macOS/Windows. murasaki installer --sign GPG-signs the artifacts — see Signing: GPG detached signatures below. .rpm and repository metadata are not implemented yet — see "What's not implemented yet" below.

murasaki bundle --target linux-x64      # dist/bundle/<Name>.AppDir/ + <Name>-<version>-linux-x64.AppImage
murasaki bundle --target linux-arm64
murasaki installer --target linux-x64   # dist/<debname>_<version>_amd64.deb
murasaki installer --target linux-arm64 # dist/<debname>_<version>_arm64.deb

Both cross-build from macOS/Windows/CI the same way bundle --target win32-x64 already does — no Linux host required. bundle needs mksquashfs on PATH to build the .AppImage (the AppDir folder itself has no extra requirement):

brew install squashfs              # macOS
apt install squashfs-tools         # Debian/Ubuntu
dnf install squashfs-tools         # Fedora

installer's .deb is a pure-Node ar/tar writer — it needs no dpkg-deb and no extra host tool.

The AppDir mirrors the macOS .app's Contents/Resources layout under usr/lib/<appId>/resources/ (client build, server actions, a downloaded Node runtime, murasaki-meta.json, …), plus the freedesktop.org pieces every Linux desktop expects: AppRun, a root and usr/share/applications/ <appId>.desktop, and a usr/share/icons/hicolor/ icon-theme fan-out generated from config.icon (16 through 512px).

Running an AppImage without FUSE

AppImages normally mount themselves via FUSE. Some hosts — notably CI runners and minimal containers — don't reliably have a working /dev/fuse. Install libfuse2 (libfuse2t64 on Ubuntu 24.04+) for the normal FUSE-mount path, or pass --appimage-extract-and-run to have the AppImage runtime extract itself to a temp directory and run from there instead — no FUSE required, at the cost of a slower startup. Murasaki's own CI (.github/workflows/app-package-linux.yml) always uses --appimage-extract-and-run for exactly this reason.

AppImage vs .deb: different update stories

  • .AppImage is murasaki's self-contained update payload and self-update target: useUpdate()'s install() journal-swaps the running .AppImage file in place (same same-volume-backup + startup-health-acknowledgement guarantees as the macOS .app.zip/Windows NSIS setup swap), then relaunches it with --appimage-extract-and-run (works whether or not the host has FUSE). A failed first launch after an update automatically rolls back to the previous version. murasaki release --manifest scans for <Name>-<version>-linux-{x64,arm64}.AppImage, see Auto-update.
  • .deb is package-manager-owned. It's never an update payload — upgrades are apt/dpkg's job, the same way an OS package manager owns any other installed package's lifecycle. murasaki release --manifest never looks for a .deb, and a .deb-installed (or manually-extracted, bare AppDir) app's useUpdate().check() reports a structured "updates are managed by the system package manager" result instead of checking a manifest — never an error.

Signing: GPG detached signatures

Murasaki owns the signing orchestration here too — it never generates or stores a GPG key for you. Supply an existing key; --sign fails closed instead of silently shipping unsigned artifacts.

pnpm exec murasaki installer --target linux-x64 --sign

--sign produces a detached, ASCII-armored GPG signature (<artifact>.sig) for the .AppImage, for the .deb, and for a combined SHA256SUMS file that lists both artifacts' checksums, so a recipient can verify either the per-file signature or the checksum manifest. When dpkg-sig is on PATH, Murasaki also opportunistically embeds a Debian-native signature directly into the .deb; this is best-effort and does not block --sign if dpkg-sig is unavailable.

The signing key resolves from $MURASAKI_GPG_KEY (a key ID, fingerprint, or email known to your local gpg keyring), then sign.linux.gpgKey in murasaki.config.ts. The passphrase comes only from $MURASAKI_GPG_PASSPHRASE or an already-unlocked gpg-agent — never from config or a file.

Recipients verify with their own copy of gpg:

gpg --verify MyApp-1.0.0-linux-x64.AppImage.sig MyApp-1.0.0-linux-x64.AppImage
gpg --verify SHA256SUMS.sig SHA256SUMS

This proves the artifact matches what your key signed; it does not by itself establish that your key is trustworthy — recipients still need your public key out-of-band (a keyserver, your website, a release note) to make that judgment call themselves.

What's not implemented yet

  • No .rpm or repository metadata (apt/dnf repo indexes).
  • No apt/dnf keyring or distro-repository trust integration for the GPG signature above — it proves artifact integrity, not repository trust.
  • No self-update for the .deb or a bare/manually-extracted AppDir — only the .AppImage has a file to swap (see above).

Release checklist

  • Build from a clean checkout with a locked dependency graph.
  • Launch the bundle before wrapping it in an installer.
  • Install, update, and uninstall on clean target machines for every platform/architecture you publish.
  • Test configured URL schemes and file associations for cold start and while the primary app instance is already running.
  • Exercise Node Main shutdown, offline startup, and failed network requests.
  • Verify macOS signatures/notarization, Windows Authenticode signatures, and Linux GPG signatures.
  • Generate and sign the Murasaki update manifest only after final payloads are immutable.
  • Publish payloads, latest.json, and latest.json.sig together.
  • Keep signing credentials and MURASAKI_UPDATE_KEY in CI secrets; never in murasaki.config.ts.

Code-signing and update signing are independent. See Security and Auto-update before enabling in-app updates.

Next

Improve this page on GitHub

On this page