Shipping Guide¶
End-to-end workflow for signing, packaging, and distributing Pulp plugins on macOS.
Overview¶
The shipping pipeline follows this order:
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¶
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¶
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¶
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:
Default Entitlements¶
The default entitlements enable: - Audio input (microphone access) - Network client (for update checks)
Check Signing Status¶
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:
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¶
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"
);
One installer for a whole product (recommended)¶
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:
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:
- 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. - 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:
CFBundleVersionis what Sparkle compares withsparkle: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*):
- Builds all formats (Release config)
- Signs with Developer ID from GitHub Secrets
- Notarizes via
notarytool - Creates PKG installers
- Generates appcast.xml, including Ed25519 signatures when a signing key is provided
- 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/ |