Here’s the last part. At the end of part 4 you had a signed, notarized and stapled .app. Good for your Mac, but not for anyone else. In this part I close the pipeline: packing the binary into a DMG that downloads as a single file, moving the repo state (bump committed, tag, push), and publishing on GitHub Releases with the DMG as an asset and the release notes taken straight from CHANGELOG.md.
Why DMG and not ZIP
You can distribute a .app in a ZIP and it works. Double-click the ZIP, it unzips, you run it. But a DMG has three things that win the comparison:
It’s a signed container. When the browser downloads the DMG, macOS applies the com.apple.quarantine attribute to the DMG, not the app. When the user opens the DMG and drags the app out, the app inherits the clean state from the signed DMG —Gatekeeper validates the DMG once and that’s it. With a ZIP, the app itself gets quarantine directly and sometimes Finder doesn’t release the attribute cleanly.
It’s a stable unit. A DMG with the app inside is one file. Uploaded as one asset, downloaded as one file, installed as one file. A ZIP forces the user to know there’s an intermediate folder.
It’s the format the Mac world expects. Not a technical reason, but it counts. A user downloading a macOS app expects a DMG.
For the App Store none of this applies —there you upload a .pkg or let Xcode handle it. But for distribution outside the store, DMG is the standard.
hdiutil create — building the DMG
macOS has shipped hdiutil forever. It’s the tool for creating, mounting, verifying, and modifying disk images. In the repo’s Fastfile this line builds the DMG:
dmg_path = "#{BUILD_DIR}/#{APP_NAME}-#{version}.dmg"
sh "hdiutil create -volname '#{APP_NAME}' " \
"-srcfolder '../#{app_path}' " \
"-ov -format UDZO '../#{dmg_path}'"
Each flag:
-volname— the name the user sees when they mount the DMG. In this case “Pokédex”. Shows up in the DMG window’s title bar and in Finder’s sidebar.-srcfolder— what goes into the DMG. You give it a file or folder andhdiutilcopies it inside. Here it takes the path to the.app.-ov— overwrite if the file already exists. Without this, if you re-run the lane and the DMG from the previous run is still there,hdiutilcomplains and aborts.-format UDZO— UDIF Zlib-compressed format. The standard compressed DMG variant. Smaller than the raw app and mounts fast.
The ../ in the paths comes from hdiutil running from fastlane/ while everything else lives at the repo root. Same for the output.
After this you have build/Pokédex-1.0.1.dmg. Anyone can download it, double-click to mount and drag the app to Applications.
spctl check on the binary
Right after the DMG, the lane does one last verification:
sh "spctl --assess --type execute --verbose '../#{app_path}' 2>&1 || true"
As covered in part 3, this simulates what Gatekeeper does. If it says accepted with source=Notarized Developer ID, all good. The || true is intentional so the output prints even if spctl exits non-zero on minor warnings. The person watching the terminal decides whether to continue.
read_changelog_section — a helper for CHANGELOG.md
The repo has a CHANGELOG.md in Keep a Changelog format:
## [Unreleased]
- Work in progress.
## [1.0.0] - 2026-09-05
First public release.
- List of the first 60 Pokémon consuming the PokeAPI.
- Detail view with height, weight, abilities, base stats.
- Distribution via Developer ID + notarization.
I want the GitHub Release notes to come from there, not written twice. For that, the Fastfile defines a Ruby helper at the bottom:
def read_changelog_section(version:)
path = "../CHANGELOG.md"
return "Release v#{version}" unless File.exist?(path)
content = File.read(path)
# Match: ## [Unreleased] or ## [1.2.0] up to the next ##.
if match = content.match(/##\s*\[(?:#{Regexp.escape(version)}|Unreleased)\](.+?)(?=\n##\s|\z)/m)
match[1].strip
else
"Release v#{version}"
end
end
It’s a regex that looks for ## [Unreleased] or ## [<version>] and captures everything up to the next ## (or the end of the file). Returns that markdown, ready to go straight into the Release description.
The fallback (return "Release v#{version}") covers two cases: CHANGELOG.md doesn’t exist, and it exists but the section wasn’t found. In either case the Release ships with a minimal title instead of blowing up the lane.
Used in the lane like this:
changelog = read_changelog_section(version: version)
And changelog gets passed as description: to the next step.
commit_version_bump, tag, and push
Before creating the Release, the repo state gets left consistent:
commit_version_bump(
xcodeproj: PROJECT,
message: "chore(release): v#{version} (build #{bump})"
)
add_git_tag(tag: "v#{version}")
push_to_git_remote(tags: true)
commit_version_bump is more specific than it looks. It doesn’t do a blind git commit -am: it stages the exact files Fastlane knows change on a bump (.xcodeproj/project.pbxproj, plists) and only those go into the commit. If you have other stray changes (which at this point you shouldn’t, because ensure_git_status_clean ran at the start), it complains.
add_git_tag creates a v1.0.1 tag on the just-made commit. Local annotated tag, not yet on the remote.
push_to_git_remote with tags: true pushes the commit and the tag to the remote in one operation. If the push fails (say main advanced while your lane was running), the lane dies here before creating the Release, which is correct —the Release would point to a commit that isn’t on the remote.
set_github_release — the publish
With the tag on the remote, the final action creates the Release and uploads the DMG:
set_github_release(
repository_name: GITHUB_REPO,
api_bearer: ENV["GITHUB_TOKEN"],
name: "v#{version}",
tag_name: "v#{version}",
description: changelog,
commitish: "main",
upload_assets: [dmg_path]
)
Under the hood, set_github_release makes two calls to the GitHub REST API:
POST /repos/{owner}/{repo}/releaseswith the Release JSON (name, tag, description). GitHub creates the Release and returns anupload_url.POSTto theupload_urlwith the DMG binary asapplication/octet-stream. GitHub attaches it to the Release as a downloadable asset.
The parameters:
repository_name—owner/repoformat. In the repo’sFastfileit’s the constantGITHUB_REPO = "slekens/pokedex-macos".api_bearer— the PAT you set up in part 2. Read fromENV["GITHUB_TOKEN"].name— the Release’s visible name (the big header).tag_name— the tag it points to. Must exist already (that’s why the push happens first).description— the Release body. Markdown. This is where theread_changelog_sectionresult lands.commitish— the base branch. Redundant if the tag already exists, but explicit.upload_assets— array of paths to upload as assets. You can upload multiple (.dmg,.zip, checksums), though in the repo only the DMG goes up.
When the step finishes, the output shows something like:
[15:23:47]: 🚀 Pokédex v1.0.1 published on GitHub Releases
And at github.com/slekens/pokedex-macos/releases the Release shows up with the DMG downloadable.
The full command, one last time
The whole pipeline, top to bottom, in a single command:
bundle exec fastlane release version:1.0.1
With that, this happens in sequence:
ensure_git_status_clean+ensure_git_branch(branch: "main").increment_version_numberto 1.0.1,increment_build_numberbumps the build by one.build_mac_appcompiles with-configuration Releaseand signs with Developer ID.notarizeuploads to Apple, waits for the verdict, staples the ticket.hdiutil createpackages the.appintobuild/Pokédex-1.0.1.dmg.spctl --assessconfirms Gatekeeper accepts the binary.commit_version_bumpleaves the version change committed.add_git_tagmarks the version.push_to_git_remotepushes commit and tag to the remote.read_changelog_sectionpulls the markdown fromCHANGELOG.md.set_github_releasecreates the Release and uploads the DMG.
The approximate times I see on my runs: the Release build takes 1-2 minutes, notarization 30 seconds to 3 minutes, the DMG 5-10 seconds, push and Release are near-instant. Total: between 3 and 6 minutes from firing the command to having a public asset ready to share.
Closing the series
Five parts:
- Overview — Gatekeeper, signing, notarization, stapling.
- Setup — certificates, API Key, PAT,
.env. - Notarization in depth —
codesign, Hardened Runtime, entitlements,notarytool, staple. - Auto-versioning and lanes — versions,
qa,release,skip_publish. - This one — DMG, tag, GitHub Release.
The pokedex-macos repo is at github.com/slekens/pokedex-macos. The Fastfile, .env.example and project.yml in the examples are literally the ones in the repo. The app itself (SwiftUI, simple MVVM, PokeAPI) is saved for a separate series later —the focus of this one was the pipeline.
If you’re going to set up the same flow in another project, the four files you’ll need to translate are: Gemfile, fastlane/Fastfile, fastlane/Appfile and .env.example. Everything else is a variation on the same skeleton.