Sign, Package, and Notarize the Exact macOS Artifact Users Install
After 51 minutes of green checks, the release job notarized the wrong app. A stale architecture-specific bundle had survived at the old path, exposing a macOS signing and notarization failure that successful commands had concealed.
TL;DR
A defensible release ties each claim to the artifact that crossed that boundary. Hashes cover byte-preserving handoffs; manifests and platform checks cover authorized transformations and the package a user actually installs.
Proving macOS signing and notarization artifact continuity
The initial correction looked trivial. The build moved to universal-apple-darwin, and the packaging script moved to the corresponding bundle path:
src-tauri/target/universal-apple-darwin/release/bundle/macos/App.appThe older architecture-specific bundle had survived at its previous path. The new build completed at the universal path, while a later signing command accepted the old path and successfully signed the stale app it found there. Packaging and notarization also succeeded because each command operated correctly on the path it received. Comparing the signed-app manifest with the declared build output catches that substitution. An existence check does not.
I treat path selection as evidence about where a step read, not what it read. A deterministic manifest identifies content crossing a handoff. When signing, packaging, or stapling changes bytes, the release record names that operation and retains the artifact produced on each side of it.
Byte equality cannot span the complete workflow. Code signing modifies the bundle. Packaging creates a new container, and stapling modifies that container. Hashes establish continuity only across operations intended to preserve content.
I begin in a fresh workspace and reject any existing application, package root, component package, or final package. That keeps output from an earlier execution from satisfying a later existence test.
Sign nested code from the inside out
The application identity signs executable code in the .app; the installer identity signs the .pkg that delivers it. Both belong to the shared developer team rather than an engineer’s local setup. Notarization credentials remain separate release inputs and do not need to enter the nested application build.
Apple’s distribution-signing guidance says not to pass --deep when signing code. Nested code is signed before its containing bundle. I either give that work to the build tool or maintain an explicit component inventory and sign it from the inside out.
The application-specific inventory keeps the signing order visible:
set -euo pipefail
APP="src-tauri/target/universal-apple-darwin/release/bundle/macos/App.app"
: "${APPLE_APPLICATION_IDENTITY:?set application identity}"
signing_order=(
"$APP/Contents/Frameworks/App.framework/Versions/A"
"$APP/Contents/Library/LoginItems/AppHelper.app"
"$APP"
)
for component in "${signing_order[@]}"; do
codesign --force --options runtime --timestamp \
--sign "$APPLE_APPLICATION_IDENTITY" "$component"
done
codesign --verify --strict --verbose=2 "$APP"That final command verifies the declared components and outer bundle at this point. It does not prove that the inventory is complete. Whenever the layout changes, I update the inventory rather than relying on recursive signing to repair unknown native code.
Record content at each handoff
I create a deterministic manifest after signing, then compare it with the staged copy and the application extracted from the component package:
set -euo pipefail
APP="src-tauri/target/universal-apple-darwin/release/bundle/macos/App.app"
PKGROOT="build/pkgroot"
COMPONENT_PKG="build/App-component.pkg"
EXPANDED="build/expanded-component"
manifest() {
root="$1"
(
cd "$root"
find . -type f -print | LC_ALL=C sort | while IFS= read -r file; do
shasum -a 256 "$file"
done
)
}
manifest "$APP" > build/signed-app.sha256
compare_manifest() {
expected="$1"
root="$2"
observed=$(mktemp)
manifest "$root" > "$observed"
cmp "$expected" "$observed"
rm -f "$observed"
}
mkdir -p "$PKGROOT/Applications"
ditto "$APP" "$PKGROOT/Applications/App.app"
compare_manifest build/signed-app.sha256 "$PKGROOT/Applications/App.app"
pkgbuild --root "$PKGROOT" --identifier "$PKG_IDENTIFIER" \
--version "$VERSION" --install-location / "$COMPONENT_PKG"
pkgutil --expand-full "$COMPONENT_PKG" "$EXPANDED"
compare_manifest build/signed-app.sha256 \
"$EXPANDED/Payload/Applications/App.app"The manifest covers regular-file contents and relative names. It omits permissions, extended attributes, and symlink targets, so I include those when they affect the release policy. Filenames containing newlines require a null-delimited implementation. Those limitations are explicit engineering costs; they do not make path comparison a substitute for content evidence.
The final package receives a new hash after productbuild signs it. Stapling produces another authorized package version, so I retain both hashes:
productbuild --package "$COMPONENT_PKG" \
--sign "$APPLE_INSTALLER_IDENTITY" dist/App.pkg
shasum -a 256 dist/App.pkg > build/signed-package.sha256
: "${NOTARY_PROFILE:?set notarization Keychain profile}"
xcrun notarytool submit dist/App.pkg --wait \
--keychain-profile "$NOTARY_PROFILE"
xcrun stapler staple dist/App.pkg
shasum -a 256 dist/App.pkg > build/stapled-package.sha256Notarization acceptance applies to the submitted outer package. Stapling then changes that package, so the published digest is the value in build/stapled-package.sha256. Gatekeeper assessment and installed-app checks remain separate evidence.
Verify the downloaded package and installed app
The runner records xcodebuild -version, xcrun --sdk macosx --show-sdk-version, the compiler version, and installed targets. These values describe the build environment; they are not artifact stages.
The following post-install check takes its expectations from release configuration. EXPECTED_MACHO_INVENTORY contains sorted paths relative to the installed app, one per line. UNIVERSAL_MACHO_INVENTORY lists only entries that require both arm64 and x86_64.
#!/usr/bin/env bash
set -euo pipefail
: "${DOWNLOADED_PKG:?set downloaded package path}"
: "${INSTALLED_APP:?set installed app path}"
: "${EXPECTED_TEAM_ID:?set expected TeamIdentifier}"
: "${EXPECTED_REQUIREMENT:?set expected designated requirement}"
: "${EXPECTED_BUNDLE_VERSION:?set expected bundle version}"
: "${EXPECTED_MACHO_INVENTORY:?set inventory file path}"
: "${UNIVERSAL_MACHO_INVENTORY:?set universal inventory file path}"
: "${EXPECTED_PKG_DIGEST:?set published package digest}"
: "${EXPECTED_INSTALLED_MANIFEST:?set extracted payload manifest}"
inventory=$(mktemp)
installed_manifest=$(mktemp)
trap 'rm -f "$inventory" "$installed_manifest"' EXIT
actual_pkg_sha=$(shasum -a 256 "$DOWNLOADED_PKG" | awk '{print $1}')
[ "$actual_pkg_sha" = "$EXPECTED_PKG_DIGEST" ]
sudo installer -pkg "$DOWNLOADED_PKG" -target /
(
cd "$INSTALLED_APP"
find . -type f -print | LC_ALL=C sort | while IFS= read -r file; do
shasum -a 256 "$file"
done
) > "$installed_manifest"
cmp "$EXPECTED_INSTALLED_MANIFEST" "$installed_manifest"
team_id=$(codesign -dvv "$INSTALLED_APP" 2>&1 |
sed -n 's/^TeamIdentifier=//p')
requirement=$(codesign -d -r- "$INSTALLED_APP" 2>&1 |
sed -n 's/^designated => //p')
version=$(defaults read \
"$INSTALLED_APP/Contents/Info" CFBundleShortVersionString)
printf '%s\n' 'DIAGNOSTIC OUTPUT'
printf 'TeamIdentifier: %s\n' "$team_id"
printf 'Designated requirement: %s\n' "$requirement"
printf 'Bundle version: %s\n' "$version"
printf '%s\n' 'Installed Mach-O inventory:'
while IFS= read -r -d '' file; do
if file -b "$file" | grep -q 'Mach-O'; then
printf '%s\n' "${file#"$INSTALLED_APP"/}"
fi
done < <(find "$INSTALLED_APP" -type f -print0) |
LC_ALL=C sort | tee "$inventory"
printf '%s\n' 'HARD RELEASE INVARIANTS'
pkgutil --check-signature "$DOWNLOADED_PKG"
xcrun stapler validate "$DOWNLOADED_PKG"
spctl --assess --type execute --verbose=4 "$INSTALLED_APP"
[ "$team_id" = "$EXPECTED_TEAM_ID" ]
[ "$requirement" = "$EXPECTED_REQUIREMENT" ]
[ "$version" = "$EXPECTED_BUNDLE_VERSION" ]
cmp "$EXPECTED_MACHO_INVENTORY" "$inventory"
codesign --verify --strict --verbose=2 "$INSTALLED_APP"
while IFS= read -r relative; do
[ -n "$relative" ] || continue
binary="$INSTALLED_APP/$relative"
grep -Fxq "$relative" "$inventory"
codesign --verify --strict --verbose=2 "$binary"
done < "$EXPECTED_MACHO_INVENTORY"
while IFS= read -r relative; do
[ -n "$relative" ] || continue
archs=$(lipo -archs "$INSTALLED_APP/$relative")
[ "$(printf '%s\n' $archs | LC_ALL=C sort | tr '\n' ' ')" = \
"arm64 x86_64 " ]
done < "$UNIVERSAL_MACHO_INVENTORY"The inventory comparison detects unexpected or missing native components. Strict verification covers every declared Mach-O file, while the architecture assertion applies only to components that release policy marks universal. The package signature and ticket are checked on the downloaded object; execution assessment runs against the installed app.
The control carries a maintenance cost. Universal bundles are larger, builds take longer, and a dependency can add native components that require an inventory update. Post-install verification also needs a controlled host. Evidence collected only on the packaging runner cannot replace it. That cost is justified only when the release claim is specific enough to audit at the installation boundary.
FAQ
Why are application and installer identities separate?
They authorize different layers. A valid installer signature does not establish that executable code inside the package carries the expected application identity.
When do two manifests match?
Manifests remain equal across content-preserving handoffs, including staging and package extraction. Signing, packaging, and stapling intentionally alter bytes, so each produces a separately recorded artifact.
Is checking the main executable enough for a universal app?
No. Native helpers and frameworks can carry different slices or signatures. I inspect the installed component inventory and apply universal-architecture assertions only to components covered by that release requirement.
