Skip to content
C.W.K.
Stream
Lesson 01 of 06 · published

From swift build to a Real, Installed .app

~17 min · bundle-signing, bundling, codesign, install, scripts

Level 0Bundle Opener
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"A green swift build is a promise. The installed, verified bundle is the proof."

The Script Replaces Xcode's Build Phases

Without an Xcode project, nothing turns a SwiftPM binary into an app for you. Every family Mac app has a scripts/build-app.sh that does it, and they all follow the same order for the same reasons:

  1. Build release and ask SwiftPM where the product landed (--show-bin-path).
  2. Assemble Spark.app/Contents/{MacOS,Resources}: the binary and the Info.plist, plus an icon when the app has one.
  3. Stamp the marketing version from the product's version file into the Info.plist with PlistBuddy, so one file decides the version.
  4. Clear extended attributes (xattr -cr). Finder metadata inside a bundle breaks code signing.
  5. Sign the finished bundle — never before it is finished, because the signature seals every file.
  6. Verify: codesign --verify --deep --strict, and check that the designated requirement is certificate-based, not a cdhash.
  7. Stage, swap, verify again, roll back on failure: copy to a hidden staging name next to /Applications, move the old app aside, move the new one in, verify the installed copy, restore the previous one if anything fails.
  8. Register with LaunchServices (lsregister -f) so the system sees the new build now.

Why Stage Instead of Copying Over

The kernel caches a signed executable's code signature per file (per inode). Overwriting a signed binary in place can leave the old signature cached against new bytes, which surfaces as a launch killed for an invalid signature. Writing a complete new bundle and renaming it into place gives the kernel a new file. The swap also means a failed install leaves the previous working app exactly where it was.

Two Details That Only Bite Once

  • Command-line helpers need an Info.plist too. A tool that asks for a privacy permission — the family's calendar bridge reads EventKit — has no bundle to hold an Info.plist, so macOS denies the request without ever showing a dialog. Embed the plist in the binary with -Xlinker -sectcreate -Xlinker __TEXT -Xlinker __info_plist -Xlinker Info.plist, then sign it.
  • Nested code is signed inside-out. Helpers, embedded executables and bundled dylibs are signed before the bundle that contains them. Apple's guidance is to sign each nested item explicitly rather than rely on codesign --deep for signing; --deep is fine for verifying. Bundling third-party dylibs and rewriting their load paths is a whole story of its own, told in /cwk-quests/ashen-reel-quest.

Code

scripts/build-app.sh — build, assemble, stamp, sign, verify, stage, swap, register·bash
#!/bin/zsh
set -euo pipefail
ROOT=${0:A:h:h}
IDENTITY=${SPARK_SIGN_IDENTITY:-"Spark Local Signing"}   # a stable, self-signed code-signing identity
BUNDLE_ID=com.example.spark
APP=$ROOT/.build/app/Spark.app
DEST=${SPARK_INSTALL_DIR:-/Applications}/Spark.app
STAGED=${DEST:h}/.Spark.app.install.$$
PREVIOUS=${DEST:h}/.Spark.app.previous.$$
LSREGISTER=/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister

verify() {
  codesign --verify --deep --strict "$1"
  local req; req=$(codesign -dr - "$1" 2>&1)
  [[ $req == *"identifier \"$BUNDLE_ID\""* && $req == *'certificate leaf = H"'* ]] || {
    print -u2 "HARD_FAIL unstable designated requirement: $req"; return 1; }
}

cd "$ROOT"
swift build -c release --product Spark
BIN=$(swift build -c release --show-bin-path)/Spark

rm -rf "$APP"
mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
install -m 755 "$BIN" "$APP/Contents/MacOS/Spark"
install -m 644 Resources/Info.plist "$APP/Contents/Info.plist"
if [[ -f Resources/Spark.icns ]]; then                        # named by CFBundleIconFile in Info.plist
  install -m 644 Resources/Spark.icns "$APP/Contents/Resources/Spark.icns"
fi
VERSION=$(/usr/bin/plutil -extract version raw -o - cwk-product.json)
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VERSION" "$APP/Contents/Info.plist"
xattr -cr "$APP"                                               # finish the bundle completely...
codesign --force --timestamp=none --sign "$IDENTITY" "$APP"    # ...then sign it
verify "$APP"

rm -rf "$STAGED"; ditto "$APP" "$STAGED"; verify "$STAGED"    # never overwrite a signed binary in place
[[ -d $DEST ]] && mv "$DEST" "$PREVIOUS"
if ! mv "$STAGED" "$DEST" || ! verify "$DEST"; then
  rm -rf "$DEST"; [[ -d $PREVIOUS ]] && mv "$PREVIOUS" "$DEST"
  print -u2 "HARD_FAIL install rolled back"; exit 1
fi
rm -rf "$PREVIOUS"
"$LSREGISTER" -f "$DEST" >/dev/null 2>&1 || true
print "SPARK_BUILD_OK $DEST version $VERSION"

External links

Exercise

Create Resources/Info.plist and cwk-product.json for Spark and save the script as scripts/build-app.sh. Run it with SPARK_SIGN_IDENTITY=- SPARK_INSTALL_DIR=$PWD/../Apps (ad-hoc signing into a scratch folder) after commenting out the certificate check in verify, and confirm the bundle appears with the stamped version. Run it a second time and confirm no staging or previous folders are left behind. Then edit the installed Info.plist by hand and run codesign --verify --deep --strict to see how a sealed bundle reports tampering.
Hint
Ad-hoc signing (--sign -) is fine for learning the pipeline, but its requirement is a cdhash — which is exactly what the certificate check in verify() refuses. The next two lessons replace it with a stable identity.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.