A universal DMG in a shell script: lipo, hdiutil, notarytool and stapling
The release script behind NotchStar's download, step by step, with the checks that run on the finished file
TL;DR
- One zsh script,
scripts/release.sh, takes NotchStar from source to a download: a universal build for Apple silicon and Intel, a signature, a notarized and stapled app, a compressed DMG, a notarized and stapled DMG, and a SHA 256 file. It stops on the first failure. - The order matters. The app is notarized and stapled before it goes into the disk image, so that it also opens cleanly when it is dragged out of the DMG on a Mac that is offline. The DMG is then signed, notarized and stapled as well.
- Checked on 4 November 2026: the 1.0.5 DMG is 3,756,785 bytes,
hdiutil verifyreports it valid,stapler validateworks on the DMG and on the app inside it, andspctlsays “Notarized Developer ID” for both.
The problem: a build is not a download
xcodebuild ends with an .app in a derived data folder. A download needs more than that. A customer on an Intel Mac and one on an Apple silicon Mac need the same file to work. The system must trust it without a warning. The package must open on a Mac with no network, and the maker must be able to prove which bytes were shipped.
Those requirements become six jobs: build for two architectures, sign with a hardened runtime, have Apple scan it, attach the result, wrap it in a disk image that shows an Applications shortcut, and check the finished file the way a customer’s Mac will. Doing them by hand once is fine. Doing them by hand for six releases, as NotchStar has (the 1.0.0 to 1.0.5 disk images all sit in release/), invites a missed step.
This post reads scripts/release.sh in the order it runs. The signing details, the certificate setup and the full notarization story are in the earlier post on signing and notarization. Here the focus is the packaging: universal binaries, the disk image, and the checks.
Approach: read the script in the order it runs
Setup and one source of truth for the version
set -euo pipefail
cd "$(dirname "$0")/.."
[ -f .signing/env ] && source .signing/env
VERSION=$(awk '/MARKETING_VERSION/ {gsub(/"/,"",$2); print $2; exit}' project.yml)
OUT="release"; APP="build/Build/Products/Release/NotchStar.app"; DMG="$OUT/NotchStar-$VERSION.dmg"
set -euo pipefail makes any failing command, unset variable or failing pipe stop the script. The file .signing/env is gitignored and holds the names of the signing identity and the notarization profile, never printed. The version comes from project.yml, the same line the Xcode project uses, so the file name cannot drift from the app’s version. Run against the repository on 4 November 2026 that awk line prints 1.0.5.
Whether to notarize is a single flag, and it is decided by what the environment provides.
NOTARIZE=0; [ -n "${DEVELOPER_ID:-}" ] && [ -n "${NOTARY_PROFILE:-}" ] && NOTARIZE=1
Without those two values the script still produces a DMG, ad hoc signed, and prints that it is not notarized. That keeps the script useful on a machine that has no signing setup, for a smoke test.
One universal build with lipo to check it
xcodegen generate -q
xcodebuild -scheme NotchStar -configuration Release -destination 'generic/platform=macOS' \
-derivedDataPath build ARCHS="arm64 x86_64" ONLY_ACTIVE_ARCH=NO build 2>&1 | grep -E "error:|\*\* BUILD" || true
[ -d "$APP" ] || { echo "build failed"; exit 1; }
lipo -info "$APP/Contents/MacOS/NotchStar" | sed 's/^/ /'
Three settings make the build universal: the destination generic/platform=macOS, ARCHS="arm64 x86_64" and ONLY_ACTIVE_ARCH=NO. The last one matters most. With it set to YES, Xcode builds only the architecture of the Mac it runs on, and this flag turns that off.
lipo -info is the check. On the Release build stored in build/ on 4 November 2026 it prints the architectures present, x86_64 arm64. lipo -detailed_info gives the sizes of the two slices.
| Part of the binary | Size in bytes |
|---|---|
| x86_64 slice | 4,643,872 |
| arm64 slice | 4,623,952 |
| Both together, the file on disk | 9,293,392 |
The file is a little larger than the sum of the two slices (9,267,824), because a universal binary also carries a small header that lists them. A universal build doubles the executable, not the whole app. The images, the Perl script and the framework are shared.
Signing, inside out
This is covered in the signing post, so only the shape is shown here: the embedded framework first, then the app, with the entitlements passed again, because signing the app alone would not give the framework the hardened runtime. The script ends this step with a verification and an entitlements check.
codesign --verify --deep --strict "$APP" && echo " signature OK"
codesign -d --entitlements - "$APP" 2>/dev/null | grep -q "device.camera" || { echo "!! entitlements are missing from the signed app"; exit 1; }
On the Developer ID path --deep appears here for verification only. It was signing with --deep that once stripped the entitlements, so it is used to check, not to sign. The ad hoc fallback branch, for machines with no signing setup, still signs with it, because there are no entitlements to protect under an ad hoc signature.
Notarize the app first, then the DMG
Apple’s notary service takes a file and answers accepted or rejected. A bare .app is a folder, so it goes up as a ZIP made with ditto, which keeps the bundle’s metadata intact.
ZIP=$(mktemp -d)/NotchStar.zip
ditto -c -k --keepParent "$APP" "$ZIP"
notarize "$ZIP"
xcrun stapler staple "$APP"
The script wraps notarytool in a function that waits for the result and refuses to continue unless it sees status: Accepted.
result=$(xcrun notarytool submit "$file" --keychain-profile "$NOTARY_PROFILE" "${KC_NOTARY[@]}" --wait 2>&1) || true
if ! echo "$result" | grep -q "status: Accepted"; then
echo "!! Apple did not accept $(basename "$file")."
[ -n "$id" ] && xcrun notarytool log "$id" --keychain-profile "$NOTARY_PROFILE" "${KC_NOTARY[@]}" | sed 's/^/ /'
exit 1
fi
--wait blocks until Apple finishes. On rejection, notarytool log prints the reasons, so the script’s output is the diagnosis and nobody has to look anything up. The credentials are a stored profile name, not a key on the command line.
The app is stapled before the DMG exists. A staple attaches the notarization ticket to the file, so the Mac does not have to ask Apple. If only the DMG carried a ticket, the app would be fine as long as it stayed in the DMG’s mounted volume, but an app dragged to Applications and copied to a Mac without internet would depend on a lookup. Stapling the app avoids that case. The script comment says exactly this: “so it also opens cleanly when it is dragged out of the disk image offline.”
How long does notarization take? The script’s own banner says “usually 2 to 10 minutes”. That is an expectation written into the script. No duration was measured for this post, because it would need a new submission to Apple.
The disk image
STAGE=$(mktemp -d); cp -R "$APP" "$STAGE/"; ln -s /Applications "$STAGE/Applications"
rm -f "$DMG"; hdiutil create -volname "NotchStar" -srcfolder "$STAGE" -ov -format UDZO -fs HFS+ "$DMG" >/dev/null
rm -rf "$STAGE"
A staging folder holds two things: the app, copied in, and a symbolic link to /Applications. That link is what shows an Applications shortcut next to the app in the opened window, so a customer drags one icon onto the other. hdiutil create takes the folder with -srcfolder and writes the image.
The two options that matter are -format UDZO and -fs HFS+. UDZO is a read only, zlib compressed image. The file system is HFS+, an older choice that every macOS version the app supports can mount. Checking the shipped 1.0.5 image on 4 November 2026:
$ hdiutil imageinfo NotchStar-1.0.5.dmg
Format Description: UDIF read-only compressed (zlib)
Total Bytes: 14669824
Compressed Ratio: 0.3348751156336725
$ hdiutil verify NotchStar-1.0.5.dmg
hdiutil: verify: checksum of "NotchStar-1.0.5.dmg" is VALID
Mounting it read only to a throwaway mount point showed exactly two entries: Applications, a symbolic link to /Applications, and NotchStar.app. The app inside is still universal (x86_64 arm64).
The DMG is signed, notarized and stapled too
codesign --force --sign "$DEVELOPER_ID" "${KC_SIGN[@]}" --timestamp "$DMG"
notarize "$DMG"
xcrun stapler staple "$DMG"
A disk image is a file a customer downloads, so it carries its own signature and ticket. There is no --options runtime on this codesign call because a disk image has no executable code to harden. On the shipped file, codesign -dv reports Format=disk image, a signature and a secure timestamp of 12 October 2026.
Verify what a customer’s Mac will say
The last step does not trust the earlier ones. It asks the system’s own policy engine.
xcrun stapler validate "$DMG"
spctl --assess --type open --context context:primary-signature -v "$DMG"
spctl --assess --type execute -v "$APP"
Run by hand against the 1.0.5 files on 4 November 2026 (the app was checked inside the mounted image):
stapler validate DMG -> The validate action worked!
stapler validate app -> The validate action worked!
spctl (DMG) -> accepted, source=Notarized Developer ID
spctl (app, in DMG) -> accepted, source=Notarized Developer ID
Last comes the checksum, which the website publishes so that a customer can compare.
SHA=$(shasum -a 256 "$DMG" | awk '{print $1}')
echo "$SHA $(basename "$DMG")" > "$OUT/NotchStar-$VERSION.dmg.sha256"
The first 16 characters of shasum -a 256 release/NotchStar-1.0.5.dmg (904ade495063e015) match the start of the sha256 value in site/src/data/site.ts, which is what the download page shows.
The wrong turn: a locked keychain after a restart
The most frustrating failure in the script is not about Apple. Signing uses a dedicated keychain created by the setup script, so that codesign never shows a password prompt. The keychain does not lock on a timer. After a restart of the Mac it is locked again, and codesign then fails with errSecInternalComponent, an error that says nothing about a locked keychain.
The script handles it before anything else runs. It unlocks the keychain with the password stored in a file whose path is in .signing/env, without printing the password, and it stops with a clear message if that fails.
security unlock-keychain -p "$(cat "$NOTARY_KEYCHAIN_PW_FILE")" "$NOTARY_KEYCHAIN" \
|| { echo "!! could not unlock $NOTARY_KEYCHAIN with $NOTARY_KEYCHAIN_PW_FILE"; exit 1; }
The comment above that block records the symptom and the cause in one place. A run after a Mac restart starts with the failure already explained. The password value never appears in the script, in the log or in this post.
Numbers, with the method
All values were read on 4 November 2026 from files that already existed. No release was built and nothing was submitted to Apple.
| Fact | Command | Value |
|---|---|---|
| Architectures in the built app | lipo -info | x86_64 and arm64 |
| Executable size, both slices | lipo -detailed_info, ls -l | 9,293,392 bytes |
| DMG size, 1.0.5 | stat -f %z | 3,756,785 bytes |
| DMG size, 1.0.0 | same | 3,316,059 bytes |
| DMG size, 1.0.2 | same | 3,497,204 bytes |
| DMG size, 1.0.4 | same | 3,623,809 bytes |
| Image format | hdiutil imageinfo | UDIF read only, zlib compressed |
| Uncompressed size | same | 14,669,824 bytes |
| Compressed ratio | same | 0.335 |
| Checksum | hdiutil verify | valid, CRC32 |
| Gatekeeper | spctl --assess | accepted, Notarized Developer ID |
The disk image has grown by about 440 KB, from 3,316,059 bytes at 1.0.0 to 3,756,785 bytes at 1.0.5, over the releases listed. The script does not record why, and no per release breakdown was made.
What is still bad
- A failed build can leave an old app behind. The script checks
[ -d "$APP" ]after a build whose output is filtered throughgrep ... || true. The derived data folderbuild/keeps the previous app, so ifxcodebuildfails and an olderNotchStar.appis still there, the script goes on to sign and package the older one. On this Macbuild/Build/Products/Release/NotchStar.appwas last modified on 11 October 2026 and is still there. A clean step before building, or a check on the build’s exit code, would close it. hdiutilprints deprecation warnings on this Mac (macOS 27.0.1).hdiutil imageinfoandhdiutil attachboth said they are deprecated and pointed todiskutil image(4 November 2026). Both still worked.hdiutil create, the command the script depends on, was not run for this post, so whether it prints the same warning is not known. The script will need the new commands at some point, and that has not been tried.- The disk image has no background picture, no custom icon positions and no license text. It is a plain window with two items.
- There is no step that compares the version in the plist with the version in the file name. They come from one source, and nothing checks that.
- The notarization duration is not recorded by the script. A slow submission looks the same as a hung one in the log.
- The script is run by one person on one Mac. It has not been run in a clean CI environment, so a dependency on that Mac’s keychain setup could be hidden.
- The final
spctlcheck runs on the maker’s Mac, where the signing certificate is installed. A fresh Mac is a stronger test. The download has not been checked on a separate clean machine by an agent.
Try it yourself
lipo -info MyApp.app/Contents/MacOS/MyApp
hdiutil create -volname MyApp -srcfolder Stage -ov -format UDZO -fs HFS+ MyApp.dmg
xcrun stapler validate MyApp.dmg
Those three lines are the core. The steps below add the signing and notarization around them.
- Build both architectures. Run
xcodebuild -scheme MyApp -configuration Release -destination 'generic/platform=macOS' -derivedDataPath build ARCHS="arm64 x86_64" ONLY_ACTIVE_ARCH=NO build. - Check it. Run
lipo -info build/Build/Products/Release/MyApp.app/Contents/MacOS/MyApp. You should see both names. - Sign with the hardened runtime. Run
codesign --force --options runtime --timestamp --sign "Developer ID Application: ..." MyApp.app, with the entitlements flag if the app needs any. - Zip and submit. Run
ditto -c -k --keepParent MyApp.app MyApp.zip, thenxcrun notarytool submit MyApp.zip --keychain-profile PROFILE --wait. - Staple. Run
xcrun stapler staple MyApp.app. - Stage the image. Make a folder, copy the app in and add the link with
ln -s /Applications Stage/Applications. - Create the image. Run
hdiutil create -volname MyApp -srcfolder Stage -ov -format UDZO -fs HFS+ MyApp.dmg. - Sign, notarize and staple the image with the same three commands.
- Verify. Run
xcrun stapler validate MyApp.dmgandspctl --assess --type open --context context:primary-signature -v MyApp.dmg.
If step 4 reports Invalid, run xcrun notarytool log SUBMISSION_ID --keychain-profile PROFILE and read the issues before changing anything.
Cheat sheet
-
set -euo pipefailat the top - Version read from the one place the project keeps it
-
ARCHS="arm64 x86_64"withONLY_ACTIVE_ARCH=NO -
lipo -infoafter the build - Sign the framework first, then the app, with entitlements
- Check the entitlements survived
- Notarize and staple the app before packaging it
- Stage the app plus an
/Applicationslink, thenhdiutil create ... -format UDZO - Sign, notarize and staple the DMG too
- Verify with
stapler validateandspctl --assess - Write a SHA 256 file next to the DMG
| Command | What it answers |
|---|---|
lipo -info FILE | Which architectures are inside |
hdiutil imageinfo DMG | Format, size, compression |
hdiutil verify DMG | Is the image intact |
xcrun notarytool submit FILE --wait | Did Apple accept it |
xcrun notarytool log ID | Why not |
xcrun stapler validate FILE | Is a ticket attached |
spctl --assess --type open --context context:primary-signature -v DMG | What Gatekeeper says about a download |
shasum -a 256 DMG | The checksum to publish |
Closing
The script builds the download for NotchStar, a Mac app that puts ten tools in the notch. If you want to try it, it is at notchstar.com, with a 7 day free trial.
NotchStar is a native Mac notch app, $4 once, with a 7 day free trial. Try it, or read the ten tools.