Skip to content

Shipping Guide

End-to-end workflow for signing, packaging, and distributing Pulp plugins on macOS.

Overview

The shipping pipeline follows this order:

Build → Validate → Sign → Notarize → Package → Distribute

Never install a plugin to system folders without passing validation first.

Shipping a plugin that hot-reloads its DSP/UI to end users? Sign the reload packs too — see reload-trust.md for pulp ship swap-pack, the trust model, and where signing keys live.

Prerequisites

  • Apple Developer ID certificate (Developer ID Application)
  • Either an App Store Connect API key (.p8, preferred) or an Apple ID with app-specific password for notarization. Store ASC creds in ~/.config/pulp/secrets/notary.env — see Step 4.
  • Xcode command-line tools installed

Step 1: Build

pulp build
# or
cmake -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build --config Release

Output appears in build/VST3/, build/CLAP/, build/AU/.

Ship from a pulp build tree. In an SDK project on macOS, pulp dev and pulp loop link plug-ins against a development-only shared Pulp runtime (PULP_DEV_SHARED_UI=ON) that loads only from the build directory. Like a PULP_DEV_BUILD dir, such a build ships from the statically linked build-ship/ that pulp ship package, release and Developer ID sign configure and build from its cache; notarize and share read build-ship/ once it exists. A step that still meets dev-shared bits prints the exact PULP_DEV_SHARED_UI=0 pulp build --all && pulp ship ... command to run. pulp ship sign --identity - (ad hoc) or an Apple Development identity signs a dev-shared build as it is, for loading on this machine only. Static builds sign, package and notarize exactly as before.

Build identity in every bundle

Every bundle pulp_add_plugin() / pulp_add_plugin_bundle() produces carries a machine-readable build record, so a support or diagnostics tool can report exactly what is installed and what it was built with from the installed bundle alone (Info.plist carries only a product version):

Bundle Location
macOS .vst3, .component, .clap, .aaxplugin, .appex, .app Contents/Resources/pulp-build-info.json
iOS and other shallow bundles (.appex, .app) pulp-build-info.json at the bundle root
LV2 <name>.lv2/pulp-build-info.json
other single-file modules (Windows/Linux) <binary>.pulp-build-info.json beside the binary

The file is written by a POST_BUILD step of the bundle's own target, so it is already present when pulp ship sign runs and is covered by the bundle's sealed resources — editing it afterwards invalidates the signature, as it should. It is a plain resource (no code, no absolute paths), so bundle relocatability, codesign, and notarization are unaffected. The AUv3 framework embedded in the extension does not get its own copy; the extension and host app do.

A shallow (iOS-family) bundle must not contain a top-level Resources/ directory (this applies to pulp.plugin-runtime.json too, which sits at the root there as well): codesign then treats it as a malformed macOS bundle and refuses to sign it ("bundle format unrecognized", or every other root file reported as unsigned code), so the extension cannot be installed. That is why the record sits at the root there. The iOS compile gate ad-hoc signs the AUv3 extension it builds (tools/scripts/check_apple_bundle_signing.sh, inside out and without --deep, as Xcode signs it), because its build runs with CODE_SIGNING_ALLOWED=NO, which hides this class of layout mistake.

Schema pulp.build-info.v1:

Field Meaning
product.name / target / format / bundle_id / version / manufacturer Product identity from pulp_add_plugin(). format is VST3, AU, CLAP, LV2, AAX, AUv3 (extension), AUv3Host (container app), or Standalone.
product.source_git_sha / source_git_dirty Product source commit and whether tracked files were modified. See below.
build.type / archs / min_os / compiler Build configuration, target architectures, deployment floor ({platform, version}), and C++ compiler id/version.
pulp_sdk.version / provenance_kind / source_git_sha The Pulp SDK the product was built against. provenance_kind is the installed SDK's sdk-provenance.json kind (release, development, unmarked) or source-tree for an in-tree build.
runtime_pins The SDK's runtime-pins.json record, embedded verbatim (or null for an SDK that predates it).
bundled_runtime_libraries[] Runtime libraries staged beside the bundle's own binary (empty for the AUv3 extension and host app, whose libraries live in the embedded framework): file, component, and pinned version (for example libwgpu_native.dylib, wgpu-native, v24.0.3.1).

Product commit. By default the commit of the git checkout that contains the CMakeLists.txt calling pulp_add_plugin() is read when the bundle is built (not at configure time, so a commit followed by a rebuild is recorded correctly), and "unknown" / null is recorded outside a checkout. Pass it explicitly for release builds from a source archive or a history-less CI checkout — either per target (pulp_add_plugin(... SOURCE_GIT_SHA <sha> SOURCE_GIT_DIRTY FALSE)) or project-wide at configure time:

cmake -B build -DCMAKE_BUILD_TYPE=Release \
      -DPULP_PRODUCT_GIT_SHA="$GITHUB_SHA" -DPULP_PRODUCT_GIT_DIRTY=FALSE

The file is refreshed whenever the bundle's binary relinks; an edit that does not relink any bundle leaves the previous record in place, so build release artifacts from a clean build directory.

SDK runtime pins. The SDK installs share/pulp/runtime-pins.json (schema pulp.runtime-pins.v1), generated from the pins' existing sources of truth rather than a second hand-maintained copy:

Field Source
pulp.sdk_version / source_git_sha / source_git_dirty / build_type Pulp project version and git state when the SDK was built
skia.release / commit / builder_ref VERSION.md of the linked Skia prebuilt
skia.asset_sha256 / asset_in_manifest The prebuilt's .skia-asset-sha256 stamp and whether tools/deps/manifest.json pins that exact archive (null for a hand-provisioned SKIA_DIR without the stamp)
skia.graphite_backend / dawn.commit Graphite runs on Dawn; the Dawn commit is decoded from the linked dawn/dawn_version.h (kDawnVersion)
webgpu.backend / wgpu_native_version / distribution_ref The configured WebGPU backend, PULP_WGPU_NATIVE_VERSION in tools/cmake/PulpDependencies.cmake, and the webgpu= entry of tools/deps/shared-source-contract.txt
js_engine.requested / default / v8_runtime_version PULP_JS_ENGINE and the resolved backend
min_os.<platform> {unit, floor} for every platform in tools/deps/min_os.json

A value that cannot be resolved (for example dawn.commit in a build without Skia) is null, never guessed. To read what a user has installed:

plutil -p ~/Library/Audio/Plug-Ins/Components/MyPlugin.component/Contents/Info.plist | grep Version
python3 -m json.tool ~/Library/Audio/Plug-Ins/Components/MyPlugin.component/Contents/Resources/pulp-build-info.json

Step 2: Validate

pulp validate

This runs: - clap-validator on .clap bundles (if installed) - dlopen test on .clap bundles (fallback) - auval on .component bundles (macOS only, requires installation) - ctest for format-specific test labels

Step 3: Sign

pulp ship sign --identity "Developer ID Application: Your Name (TEAMID)"

Signs all plugin bundles in build/ with: - --force (re-signs if already signed) - --timestamp (Apple timestamp server) - --options runtime (hardened runtime, required for notarization) - Default entitlements from ship/templates/entitlements.plist

Custom entitlements:

pulp ship sign --identity "..." --entitlements path/to/custom.plist

Default Entitlements

The default entitlements enable: - Audio input (microphone access) - Network client (for update checks)

Check Signing Status

pulp ship check

Runs codesign --verify --deep --strict on each bundle.

Non-Interactive Keychain Access

If codesign can list the Developer ID identity but signing fails with errSecInternalComponent, the private key is installed but the current process is not allowed to use it. GUI Terminal sessions may prompt and succeed while agents, CI jobs, launchd services, or other non-interactive shells fail.

The durable setup is a dedicated signing keychain for automation. Export the Developer ID Application certificate and private key as a .p12 from Keychain Access, then run this once from zsh:

KC="$HOME/Library/Keychains/pulp-signing.keychain-db"

read -rs "KC_PW?New signing keychain password: "; echo
security create-keychain -p "$KC_PW" "$KC"
security set-keychain-settings -lut 21600 "$KC"
security unlock-keychain -p "$KC_PW" "$KC"
security list-keychains -d user -s "$KC" "$HOME/Library/Keychains/login.keychain-db"

read -rs "P12_PW?Developer ID .p12 password: "; echo
security import "/path/to/DeveloperIDApplication.p12" \
  -k "$KC" \
  -P "$P12_PW" \
  -T /usr/bin/codesign \
  -T /usr/bin/productsign \
  -T /usr/bin/pkgbuild \
  -T /usr/bin/productbuild

security set-key-partition-list -S apple-tool:,apple: -s -k "$KC_PW" "$KC"
unset KC_PW P12_PW
security find-identity -v -p codesigning "$KC"

Before a non-interactive signing run, unlock that keychain and make sure it is in the user search list:

KC="$HOME/Library/Keychains/pulp-signing.keychain-db"
read -rs "KC_PW?Signing keychain password: "; echo
security unlock-keychain -p "$KC_PW" "$KC"
security list-keychains -d user -s "$KC" "$HOME/Library/Keychains/login.keychain-db"
unset KC_PW

For a personal machine that already has the Developer ID private key in the login keychain, this narrower repair is usually enough:

KC="$HOME/Library/Keychains/login.keychain-db"
read -rs "LOGIN_PW?Mac login password: "; echo
security unlock-keychain -p "$LOGIN_PW" "$KC"
security set-key-partition-list -S apple-tool:,apple: -s -k "$LOGIN_PW" "$KC"
unset LOGIN_PW

Step 4: Notarize Packaged Artifacts

Use this after pulp ship package, or use pulp ship release to run the sign/package/notarize stages in one command. The preferred CLI path uses an App Store Connect API key (.p8) and points at the packaged .pkg, .dmg, or .zip artifact:

pulp ship notarize --path artifacts/MyPlugin-1.0.0.pkg \
                   --api-key ~/.config/pulp/secrets/AuthKey_XXX.p8 \
                   --api-key-id XXX \
                   --api-issuer 5e8f0b95-3e2f-48e7-b7c2-52e7c220502a

Stash the credentials once in ~/.config/pulp/secrets/notary.env and the CLI resolves them automatically:

# ~/.config/pulp/secrets/notary.env  (chmod 600)
PULP_NOTARY_KEY_PATH="$HOME/.config/pulp/secrets/AuthKey_XXX.p8"
PULP_NOTARY_KEY_ID="XXX"
PULP_NOTARY_ISSUER_ID="5e8f0b95-3e2f-48e7-b7c2-52e7c220502a"

Then:

pulp ship notarize --path artifacts/MyPlugin-1.0.0.pkg
pulp ship notarize --path artifacts/MyPlugin-1.0.0.pkg --dry-run

The legacy Apple-ID + app-specific-password lane still works:

pulp ship notarize --path artifacts/MyPlugin-1.0.0.pkg \
                   --apple-id you@example.com --team-id ABCDE12345
# password defaults to @keychain:AC_PASSWORD — store via
#   security add-generic-password -s AC_PASSWORD -a you@example.com -w

Programmatic API:

#include <pulp/ship/codesign.hpp>

// Submit the distributable archive you will ship, such as the .pkg or .dmg
// produced by packaging. Do not submit a raw .clap/.vst3/.component directory.
const char* archive = "artifacts/MyPlugin-1.0.0.pkg";

// App Store Connect API key (preferred)
auto uuid = pulp::ship::notarize_submit_asc(
    archive,
    "/path/to/AuthKey_XXX.p8", "XXX", "5e8f0b95-...");

// Legacy Apple-ID flow
// auto uuid = pulp::ship::notarize_submit(
//     archive, "you@apple.id", "TEAMID", "@keychain:AC_PASSWORD");

auto status = pulp::ship::notarize_check_asc(
    *uuid, "/path/to/AuthKey_XXX.p8", "XXX", "5e8f0b95-...");
pulp::ship::notarize_staple(archive);

The CI workflow (sign-and-release.yml) automates this on tag pushes.

Step 5: Package

App Icons

Standalone targets and app targets can attach an icon source directly from CMake:

pulp_app_icon(MyPlugin_Standalone
    SOURCE assets/app-icon.png
)

If you never call pulp_app_icon(...), the target builds with its default icon behavior. Removing the call cleanly removes the custom-icon pipeline again.

Current behavior: - macOS bundles a generated AppIcon.icns - Windows links a generated .ico - Android scaffolds generated launcher PNGs under android/app/src/main/res-generated/ - iOS records the selected source for downstream packaging and warns that asset-catalog emission is not implemented yet

The source image must be a PNG that is at least 1024×1024.

Per-platform overrides and debug/release variants are optional:

pulp_app_icon(MyPlugin_Standalone
    SOURCE       assets/app-icon.png
    WINDOWS      assets/app-icon-win.png
    DEBUG_ICON   assets/app-icon-dev.png
    RELEASE_ICON assets/app-icon.png
)

PKG Installer

pulp ship package --version 1.0.0

Creates .pkg installers in artifacts/ for each plugin bundle, with correct install locations: - VST3 → /Library/Audio/Plug-Ins/VST3/ - CLAP → /Library/Audio/Plug-Ins/CLAP/ - AU → ~/Library/Audio/Plug-Ins/Components/

DMG Disk Image

pulp::ship::create_dmg("build/Standalone/MyPlugin.app",
                        "artifacts/MyPlugin-1.0.0.dmg",
                        "MyPlugin 1.0.0");

Creates a DMG with an Applications alias for drag-to-install.

Combined Multi-Format Installer

#include <pulp/ship/codesign.hpp>
#include <vector>

std::vector<pulp::ship::InstallComponent> components = {
    {"build/VST3/MyPlugin.vst3", "/Library/Audio/Plug-Ins/VST3", "VST3"},
    {"build/CLAP/MyPlugin.clap", "/Library/Audio/Plug-Ins/CLAP", "CLAP"},
    {"build/AU/MyPlugin.component",
     "/Library/Audio/Plug-Ins/Components", "Audio Unit (AU)"},
};

pulp::ship::create_combined_pkg(
    components,
    "artifacts/MyPlugin-1.0.0-All.pkg",
    "com.mycompany.myplugin",
    "1.0.0"
);

Most products ship more than one artifact — several formats, sometimes several plugins, sometimes a standalone app. Ship them as one signed, notarized installer whose Customize pane lets the user pick, rather than a pile of separate downloads. The shared recipe does the packaging, signing, notarizing and stapling:

tools/scripts/build_combined_installer.sh \
  --name MyPlugin --version 1.0.0 \
  --sign-identity <Developer ID Application hash> \
  --installer-identity <Developer ID Installer hash> \
  --out artifacts/ \
  --plugin au   "build/AU/MyPlugin.component" \
  --plugin vst3 "build/VST3/MyPlugin.vst3" \
  --plugin clap "build/CLAP/MyPlugin.clap" \
  --plugin aax  "signed/MyPlugin.aaxplugin" \
  --app "Standalone app" "build/MyPlugin.app"

--plugin and --app are repeatable, and --plugin accumulates per format — so a product with three AU plugins, two VST3s and three CLAPs passes eight --plugin flags and gets one installer with a nested Customize tree. An aax bundle is the one input the recipe does not sign: hand it the bundle wraptool sign already wrapped and sealed, and the recipe verifies that signature and the PACE wrap (wraptool verify) before packaging it as a "Pro Tools" choice. On a machine without wraptool the AAX is refused unless you pass --allow-unverified-aax-wrap. See the ship skill for why re-signing an AAX breaks it. Notarization is on by default (--no-notarize opts out), and the signing preflight runs automatically, so no separate pulp ship doctor is needed.

The convention is a small package.sh at your project root that holds only the inputs and execs the shared recipe — see examples/super-convolver/package.sh. Keep the packaging logic in the shared tool so every product signs identically.

Packaging a project that consumes the installed SDK

If your project lives outside the Pulp source tree and builds against an installed SDK, note that build_combined_installer.sh is not installed into the SDK prefix (the prefix carries bin external include lib only). Point PULP_ROOT at a Pulp source checkout:

PULP_ROOT=/path/to/pulp ./package.sh

Have package.sh fail with an explicit message when it cannot find the recipe, rather than silently producing an unsigned or partial installer. Likewise assert that every expected artifact exists before invoking the recipe — a Customize pane that quietly lost a format still looks like a successful build.

This indirection is a known gap: pulp ship itself also requires the Pulp source tree, because it resolves a project root containing core/. Tracked in #6714; when it lands, an SDK consumer will be able to run pulp ship package directly and the PULP_ROOT step goes away.

Step 6: Distribute

Appcast for Auto-Updates

Generate a Sparkle-compatible appcast:

#include <pulp/ship/appcast.hpp>
#include <stdexcept>

pulp::ship::AppcastItem item;
item.version      = "1.0.1";
item.download_url = "https://example.com/MyPlugin-1.0.1.dmg";
item.description  = "Bug fixes and performance improvements.";

// Ed25519 signing accepts a Sparkle-style base64 private key: either
// a 32-byte seed or a 64-byte secret key.
auto sig = pulp::ship::sign_file_ed25519(local_file_path, private_key_b64);
if (!sig) {
    throw std::runtime_error("Ed25519 signing failed");
}
item.ed_signature = *sig;

pulp::ship::Appcast feed;
feed.items.push_back(item);
auto xml = feed.to_xml();

In-app updates for a standalone app (Sparkle 2)

A standalone app can update itself, and everything its installer package carries, from a feed of signed installer packages:

  1. Keys, once. Generate an Ed25519 key pair (Sparkle's generate_keys, or any Ed25519 tool that writes the 32-byte seed as one line of base64). Keep the private key outside the repository — for example ~/.config/pulp/secrets/sparkle/<app>_ed25519, chmod 600, with a durable copy in your password manager. Losing it means installed copies can never be updated again: Sparkle has no key rotation for an app already in the field.
  2. Embed the updater in the app only.
pulp_add_sparkle(MyPlugin_Standalone
    FEED_URL      "https://github.com/me/myplugin/releases/latest/download/appcast.xml"
    PUBLIC_ED_KEY "<public key>"
    RELEASES_URL  "https://github.com/me/myplugin/releases"
    INSTALLER     package
    AUTOMATIC_CHECKS ON)

The app gains Check for Updates… directly under About in its application menu and an Updates group in Settings; a custom editor adds its own Settings row in a few lines (In-app updates). Plug-in bundles never carry Sparkle and show no update controls; updating the app updates them because the update is the whole installer package. 3. Package as usual. build_combined_installer.sh signs the embedded framework inside-out with the Developer ID identity and the hardened runtime and notarizes the package with everything else. 4. Publish the feed beside the package.

pulp ship appcast --url artifacts/MyPlugin-1.2.0.pkg \
  --download-url https://github.com/me/myplugin/releases/download/v1.2.0/MyPlugin-1.2.0.pkg \
  --version 1.2.0 --build-number 1.2.0 --min-os 13.0 \
  --sign-key-file ~/.config/pulp/secrets/sparkle/myplugin_ed25519 \
  --notes-html-file artifacts/notes-1.2.0.html \
  --full-release-notes-url https://github.com/me/myplugin/releases/tag/v1.2.0 \
  --output artifacts/appcast.xml

Upload appcast.xml as an asset of the same release. A feed URL of the form https://github.com/<owner>/<repo>/releases/latest/download/appcast.xml always resolves to the newest non-prerelease release, so a preview published as a GitHub prerelease can never reach release users — but every release that is not marked prerelease must carry an appcast.xml, or the feed 404s. Practice previews against a separate feed (a different FEED_URL in a test build, a fixed prerelease tag that hosts appcast-beta.xml, or a loopback http://127.0.0.1 feed — Sparkle refuses file://) rather than a channel inside the release feed.

Things that matter for installer-package updates:

  • CFBundleVersion is what Sparkle compares with sparkle:version (--build-number), so it must increase with every release.
  • Sparkle installs a package with /usr/sbin/installer -target / after an administrator prompt, never silently, and with the package's default choices. A component the user deselected in the original install is installed again.
  • The updater can only update copies that already contain it. The first release that embeds Sparkle has to be installed by hand; it is the first version that can update itself to the one after it.
  • Sparkle 2.7.3+ may refuse to install from a development build whose helper tools were not re-signed; practise with a signed, notarized build.

CI Release Pipeline

The sign-and-release.yml workflow runs on version tags (v*):

  1. Builds all formats (Release config)
  2. Signs with Developer ID from GitHub Secrets
  3. Notarizes via notarytool
  4. Creates PKG installers
  5. Generates appcast.xml, including Ed25519 signatures when a signing key is provided
  6. Creates GitHub Release with artifacts

Plugin Install Locations (macOS)

Format System (all users) User
VST3 /Library/Audio/Plug-Ins/VST3/ ~/Library/Audio/Plug-Ins/VST3/
AU — ~/Library/Audio/Plug-Ins/Components/
CLAP /Library/Audio/Plug-Ins/CLAP/ ~/Library/Audio/Plug-Ins/CLAP/
Standalone /Applications/ ~/Applications/