# A universal DMG in a shell script: lipo, hdiutil, notarytool and stapling

> Build a universal Mac app, create a DMG with hdiutil, notarize with notarytool, staple, and verify with spctl. A real release.sh with checks and its limits.

Canonical: https://www.notchstar.com/blog/universal-dmg-shell-script-lipo-hdiutil-notarytool/
Last updated: 2026-11-04

*The release script behind NotchStar's download, step by step, with the checks that run on the finished file*

Published 2026-11-04.

## 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 verify` reports it valid, `stapler validate` works on the DMG and on the app inside it, and `spctl` says "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

```sh
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.

```sh
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

```sh
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.

```sh
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.

```sh
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`.

```sh
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

```sh
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:

```text
$ 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

```sh
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.

```sh
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):

```text
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.

```sh
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.

```sh
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 through `grep ... || true`. The derived data folder `build/` keeps the previous app, so if `xcodebuild` fails and an older `NotchStar.app` is still there, the script goes on to sign and package the older one. On this Mac `build/Build/Products/Release/NotchStar.app` was 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.
- `hdiutil` prints deprecation warnings on this Mac (macOS 27.0.1). `hdiutil imageinfo` and `hdiutil attach` both said they are deprecated and pointed to `diskutil 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 `spctl` check 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

```sh
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.

1. **Build both architectures.** Run `xcodebuild -scheme MyApp -configuration Release -destination 'generic/platform=macOS' -derivedDataPath build ARCHS="arm64 x86_64" ONLY_ACTIVE_ARCH=NO build`.
2. **Check it.** Run `lipo -info build/Build/Products/Release/MyApp.app/Contents/MacOS/MyApp`. You should see both names.
3. **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.
4. **Zip and submit.** Run `ditto -c -k --keepParent MyApp.app MyApp.zip`, then `xcrun notarytool submit MyApp.zip --keychain-profile PROFILE --wait`.
5. **Staple.** Run `xcrun stapler staple MyApp.app`.
6. **Stage the image.** Make a folder, copy the app in and add the link with `ln -s /Applications Stage/Applications`.
7. **Create the image.** Run `hdiutil create -volname MyApp -srcfolder Stage -ov -format UDZO -fs HFS+ MyApp.dmg`.
8. **Sign, notarize and staple the image** with the same three commands.
9. **Verify.** Run `xcrun stapler validate MyApp.dmg` and `spctl --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 pipefail` at the top
- [ ] Version read from the one place the project keeps it
- [ ] `ARCHS="arm64 x86_64"` with `ONLY_ACTIVE_ARCH=NO`
- [ ] `lipo -info` after 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 `/Applications` link, then `hdiutil create ... -format UDZO`
- [ ] Sign, notarize and staple the DMG too
- [ ] Verify with `stapler validate` and `spctl --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](https://www.notchstar.com/), with a 7 day free trial.

## More posts

- [XcodeGen for a one person Mac app: project.yml, tests and a reproducible build](https://www.notchstar.com/blog/xcodegen-for-a-one-person-mac-app/)
- [Measuring what a menu bar app costs while idle: a method, with numbers](https://www.notchstar.com/blog/measure-menu-bar-app-idle-cpu-memory-mac/)
