diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index a3b06c9..2388eb7 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -88,6 +88,8 @@ jobs:
restore-keys: swiftpm-${{ runner.os }}-${{ runner.arch }}-
- name: Resolve Swift dependencies
run: swift package resolve
+ - name: Test DMG packaging
+ run: bash Tests/Scripts/run-dmg-packaging-tests.sh
- name: Build debug configuration
run: swift build
- name: Build release configuration
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index f61516e..fd062ab 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -7,7 +7,7 @@ on:
concurrency:
group: release-${{ github.ref }}
- cancel-in-progress: true
+ cancel-in-progress: false
permissions: {}
@@ -22,10 +22,69 @@ jobs:
contents: write
issues: write
pull-requests: write
+ outputs:
+ release_created: ${{ steps.release.outputs.release_created }}
+ release_sha: ${{ steps.release.outputs.sha }}
+ tag_name: ${{ steps.release.outputs.tag_name }}
+ version: ${{ steps.release.outputs.version }}
steps:
- name: Run release-please
+ id: release
uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5
with:
token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
config-file: release-please-config.json
manifest-file: .release-please-manifest.json
+
+ release-assets:
+ name: Release assets
+ needs: release-please
+ if: needs.release-please.outputs.release_created == 'true'
+ runs-on: macos-26
+ timeout-minutes: 30
+ permissions:
+ # gh release upload adds the DMG and checksum to the release.
+ contents: write
+ steps:
+ - name: Check out release
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+ ref: ${{ needs.release-please.outputs.release_sha }}
+ - name: Restore package build cache
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
+ with:
+ path: |
+ .build/package-dmg/swift/artifacts
+ .build/package-dmg/swift/checkouts
+ .build/package-dmg/swift/repositories
+ .build/package-dmg/DerivedData/SourcePackages
+ ~/Library/Caches/org.swift.swiftpm
+ key: release-dmg-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('Package.resolved', 'Package.swift', 'project.yml') }}
+ restore-keys: release-dmg-${{ runner.os }}-${{ runner.arch }}-
+ - name: Install XcodeGen
+ env:
+ XCODEGEN_SHA256: 4d9e34b62172d645eed6457cac13fc222569974098ef4ee9c3368bedf0196806
+ XCODEGEN_VERSION: 2.46.0
+ run: |
+ archive="${RUNNER_TEMP}/xcodegen.zip"
+ curl --fail --location --silent --show-error \
+ --output "${archive}" \
+ "https://github.com/yonaskolb/XcodeGen/releases/download/${XCODEGEN_VERSION}/xcodegen.zip"
+ echo "${XCODEGEN_SHA256} ${archive}" | shasum -a 256 --check
+ unzip -q "${archive}" -d "${RUNNER_TEMP}"
+ echo "${RUNNER_TEMP}/xcodegen/bin" >> "${GITHUB_PATH}"
+ - name: Build release assets
+ env:
+ VERSION: ${{ needs.release-please.outputs.version }}
+ run: bash scripts/package-dmg.sh "${VERSION}" .build/releases
+ - name: Upload release assets
+ env:
+ GH_TOKEN: ${{ github.token }}
+ TAG_NAME: ${{ needs.release-please.outputs.tag_name }}
+ VERSION: ${{ needs.release-please.outputs.version }}
+ run: |
+ gh release upload "${TAG_NAME}" \
+ ".build/releases/clamshellctl-v${VERSION}.dmg" \
+ ".build/releases/clamshellctl-v${VERSION}.dmg.sha256" \
+ --clobber
diff --git a/App/ClamshellApp/Info.plist b/App/ClamshellApp/Info.plist
index ddc2e81..b4432b2 100644
--- a/App/ClamshellApp/Info.plist
+++ b/App/ClamshellApp/Info.plist
@@ -17,7 +17,7 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 1.0
+ $(MARKETING_VERSION)
CFBundleURLTypes
@@ -32,7 +32,7 @@
CFBundleVersion
- 1
+ $(CURRENT_PROJECT_VERSION)
LSApplicationCategoryType
public.app-category.utilities
LSUIElement
diff --git a/App/ClamshellControl/Info.plist b/App/ClamshellControl/Info.plist
index 42366c8..53ea081 100644
--- a/App/ClamshellControl/Info.plist
+++ b/App/ClamshellControl/Info.plist
@@ -17,9 +17,9 @@
CFBundlePackageType
XPC!
CFBundleShortVersionString
- 1.0
+ $(MARKETING_VERSION)
CFBundleVersion
- 1
+ $(CURRENT_PROJECT_VERSION)
NSExtension
NSExtensionPointIdentifier
diff --git a/Tests/Scripts/run-dmg-packaging-tests.sh b/Tests/Scripts/run-dmg-packaging-tests.sh
new file mode 100755
index 0000000..6375227
--- /dev/null
+++ b/Tests/Scripts/run-dmg-packaging-tests.sh
@@ -0,0 +1,287 @@
+#!/bin/bash
+set -euo pipefail
+
+repository_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+readonly repository_root
+readonly packaging_library="$repository_root/scripts/lib/dmg-packaging.sh"
+readonly embed_script="$repository_root/scripts/embed-command-products.sh"
+readonly package_script="$repository_root/scripts/package-dmg.sh"
+temporary_root=""
+
+cleanup() {
+ [[ -n "$temporary_root" ]] || return 0
+ hdiutil detach -force "$temporary_root/mount" >/dev/null 2>&1 || true
+ rm -rf "$temporary_root" || true
+}
+
+fail() {
+ echo "FAIL: $*" >&2
+ exit 1
+}
+
+assert_contains() {
+ local expected="$1"
+ local actual="$2"
+
+ [[ "$actual" == *"$expected"* ]] ||
+ fail "expected output to contain '$expected', got: $actual"
+}
+
+assert_fails() {
+ local expected="$1"
+ shift
+
+ local output
+ if output="$("$@" 2>&1)"; then
+ fail "expected command to fail: $*"
+ fi
+ assert_contains "$expected" "$output"
+}
+
+write_info_plist() {
+ local path="$1"
+ local identifier="$2"
+ local executable="$3"
+ local package_type="$4"
+
+ cat >"$path" <
+
+
+
+ CFBundleExecutable
+ $executable
+ CFBundleIdentifier
+ $identifier
+ CFBundlePackageType
+ $package_type
+ CFBundleShortVersionString
+ 1.2.3
+ CFBundleVersion
+ 1
+
+
+PLIST
+}
+
+compile_universal_executable() {
+ local output="$1"
+ local source="$output.c"
+
+ printf 'int main(void) { return 0; }\n' >"$source"
+
+ xcrun clang \
+ -arch arm64 \
+ -arch x86_64 \
+ -o "$output" \
+ "$source"
+ rm "$source"
+}
+
+create_fixture() {
+ local fixture_root="$1"
+ local app="$fixture_root/Clamshell.app"
+ local extension="$app/Contents/PlugIns/ClamshellControl.appex"
+
+ mkdir -p \
+ "$app/Contents/MacOS" \
+ "$app/Contents/Resources" \
+ "$extension/Contents/MacOS"
+ write_info_plist \
+ "$app/Contents/Info.plist" \
+ "uk.co.lmliam.clamshell.fixture" \
+ "Clamshell" \
+ "APPL"
+ write_info_plist \
+ "$extension/Contents/Info.plist" \
+ "uk.co.lmliam.clamshell.fixture.control" \
+ "ClamshellControl" \
+ "XPC!"
+ compile_universal_executable "$app/Contents/MacOS/Clamshell"
+ compile_universal_executable "$extension/Contents/MacOS/ClamshellControl"
+ compile_universal_executable "$fixture_root/clamshellctl"
+ compile_universal_executable "$fixture_root/clamshellctl-helper"
+ cat >"$fixture_root/control-entitlements.plist" <<'PLIST'
+
+
+
+
+ com.apple.security.app-sandbox
+
+
+
+PLIST
+ codesign \
+ --force \
+ --sign - \
+ --entitlements "$fixture_root/control-entitlements.plist" \
+ "$extension"
+ codesign --force --sign - "$app"
+}
+
+attach_dmg() {
+ local dmg="$1"
+ local mount_point="$2"
+ local output
+
+ mkdir -p "$mount_point"
+ if ! output="$(hdiutil attach \
+ -readonly \
+ -nobrowse \
+ -mountpoint "$mount_point" \
+ "$dmg" 2>&1)"; then
+ fail "could not attach $dmg: $output"
+ fi
+}
+
+run_tests() {
+ [[ -f "$packaging_library" ]] || fail "missing $packaging_library"
+ # shellcheck source=/dev/null
+ source "$packaging_library"
+
+ temporary_root="$(mktemp -d "${TMPDIR:-/tmp}/clamshellctl-dmg-tests.XXXXXX")"
+ trap cleanup EXIT
+
+ create_fixture "$temporary_root/fixture"
+ mkdir -p "$temporary_root/output"
+
+ assert_fails \
+ "version must use MAJOR.MINOR.PATCH" \
+ bash "$package_script" "v1.2.3" "$temporary_root/output"
+ assert_fails \
+ "CLI payload is not a regular file" \
+ bash "$embed_script" \
+ "$temporary_root/fixture/Clamshell.app" \
+ "$temporary_root/fixture/missing-cli" \
+ "$temporary_root/fixture/clamshellctl-helper"
+ assert_fails \
+ "helper payload is not a regular file" \
+ bash "$embed_script" \
+ "$temporary_root/fixture/Clamshell.app" \
+ "$temporary_root/fixture/clamshellctl" \
+ "$temporary_root/fixture/missing-helper"
+
+ local missing_extension_app="$temporary_root/missing-extension.app"
+ ditto "$temporary_root/fixture/Clamshell.app" "$missing_extension_app"
+ rm -r "$missing_extension_app/Contents/PlugIns/ClamshellControl.appex"
+ assert_fails \
+ "control extension is missing" \
+ bash "$embed_script" \
+ "$missing_extension_app" \
+ "$temporary_root/fixture/clamshellctl" \
+ "$temporary_root/fixture/clamshellctl-helper"
+
+ local thin_cli="$temporary_root/thin-clamshellctl"
+ xcrun clang -arch arm64 -x c -o "$thin_cli" - <<'SOURCE'
+int main(void) { return 0; }
+SOURCE
+ assert_fails \
+ "CLI payload must contain arm64 and x86_64" \
+ bash "$embed_script" \
+ "$temporary_root/fixture/Clamshell.app" \
+ "$thin_cli" \
+ "$temporary_root/fixture/clamshellctl-helper"
+
+ local packaged_app="$temporary_root/packaged/Clamshell.app"
+ mkdir -p "$(dirname "$packaged_app")"
+ ditto "$temporary_root/fixture/Clamshell.app" "$packaged_app"
+ bash "$embed_script" \
+ "$packaged_app" \
+ "$temporary_root/fixture/clamshellctl" \
+ "$temporary_root/fixture/clamshellctl-helper"
+
+ local embedded_entitlements="$temporary_root/embedded-entitlements.plist"
+ codesign \
+ --display \
+ --entitlements :- \
+ "$packaged_app/Contents/PlugIns/ClamshellControl.appex" \
+ >"$embedded_entitlements" 2>/dev/null
+ [[ "$(/usr/libexec/PlistBuddy -c 'Print :com.apple.security.app-sandbox' "$embedded_entitlements")" == "true" ]] ||
+ fail "embedding removed the control extension sandbox entitlement"
+
+ # The sourced verifier invokes this test double.
+ # shellcheck disable=SC2329
+ codesign() {
+ if [[ "$1" == "--display" ]]; then
+ echo "Signature=Developer ID Application"
+ return 0
+ fi
+ command codesign "$@"
+ }
+ assert_fails \
+ "CLI payload must use an ad hoc signature" \
+ verify_ad_hoc_signature \
+ "CLI payload" \
+ "$packaged_app/Contents/MacOS/clamshellctl"
+ unset -f codesign
+
+ local invalid_signature_cli="$temporary_root/invalid-signature-clamshellctl"
+ ditto "$temporary_root/fixture/clamshellctl" "$invalid_signature_cli"
+ codesign --force --sign - "$invalid_signature_cli"
+ printf 'invalid' >>"$invalid_signature_cli"
+ assert_fails \
+ "CLI payload has an invalid signature" \
+ verify_ad_hoc_signature \
+ "CLI payload" \
+ "$invalid_signature_cli"
+
+ printf 'invalid' >>"$packaged_app/Contents/Resources/clamshellctl-helper"
+ assert_fails \
+ "helper payload has an invalid signature" \
+ create_release_dmg \
+ "1.2.3" \
+ "$temporary_root/output" \
+ "$packaged_app"
+
+ ditto "$temporary_root/fixture/clamshellctl-helper" \
+ "$packaged_app/Contents/Resources/clamshellctl-helper"
+ sign_app_bundle "$packaged_app"
+
+ local outside_artifact="$temporary_root/outside.dmg"
+ ln -s "$outside_artifact" "$temporary_root/output/clamshellctl-v1.2.3.dmg"
+ assert_fails \
+ "output path must stay inside the output directory" \
+ create_release_dmg \
+ "1.2.3" \
+ "$temporary_root/output" \
+ "$packaged_app"
+ rm "$temporary_root/output/clamshellctl-v1.2.3.dmg"
+
+ create_release_dmg "1.2.3" "$temporary_root/output" "$packaged_app"
+
+ local dmg="$temporary_root/output/clamshellctl-v1.2.3.dmg"
+ local checksum="$dmg.sha256"
+ [[ -f "$dmg" ]] || fail "release DMG was not created"
+ [[ -f "$checksum" ]] || fail "release checksum was not created"
+ hdiutil verify "$dmg" >/dev/null
+ if spctl \
+ --assess \
+ --type open \
+ --context context:primary-signature \
+ "$dmg" >/dev/null 2>&1; then
+ fail "unnotarised DMG was accepted by Gatekeeper"
+ fi
+ (
+ cd "$temporary_root/output"
+ shasum -a 256 -c "$(basename "$checksum")" >/dev/null
+ )
+ [[ "$(cat "$checksum")" == *" $(basename "$dmg")" ]] ||
+ fail "checksum must contain only the DMG basename"
+ [[ "$(cat "$checksum")" != *"$temporary_root"* ]] ||
+ fail "checksum contains a local path"
+
+ attach_dmg "$dmg" "$temporary_root/mount"
+ [[ -d "$temporary_root/mount/Clamshell.app" ]] ||
+ fail "DMG does not contain Clamshell.app"
+ [[ -L "$temporary_root/mount/Applications" ]] ||
+ fail "DMG does not contain the Applications symlink"
+ [[ "$(readlink "$temporary_root/mount/Applications")" == "/Applications" ]] ||
+ fail "Applications symlink has the wrong target"
+ local entry_count
+ entry_count="$(find "$temporary_root/mount" -mindepth 1 -maxdepth 1 | wc -l | tr -d ' ')"
+ [[ "$entry_count" == "2" ]] || fail "DMG must contain exactly two entries"
+
+ echo "All DMG packaging tests passed."
+}
+
+run_tests
diff --git a/project.yml b/project.yml
index 8a8ccbf..cc3ea32 100644
--- a/project.yml
+++ b/project.yml
@@ -29,6 +29,8 @@ targets:
path: App/ClamshellApp/Info.plist
properties:
CFBundleDisplayName: Clamshell
+ CFBundleShortVersionString: "$(MARKETING_VERSION)"
+ CFBundleVersion: "$(CURRENT_PROJECT_VERSION)"
CFBundleURLTypes:
- CFBundleTypeRole: Viewer
CFBundleURLName: uk.co.lmliam.clamshell.control-action
@@ -65,6 +67,8 @@ targets:
path: App/ClamshellControl/Info.plist
properties:
CFBundleDisplayName: Clamshell Control
+ CFBundleShortVersionString: "$(MARKETING_VERSION)"
+ CFBundleVersion: "$(CURRENT_PROJECT_VERSION)"
NSExtension:
NSExtensionPointIdentifier: com.apple.widgetkit-extension
dependencies:
diff --git a/scripts/check.sh b/scripts/check.sh
index 4810d81..17774fd 100755
--- a/scripts/check.sh
+++ b/scripts/check.sh
@@ -24,6 +24,7 @@ swiftlint lint --strict
swift test
swift build
swift build -c release
+bash Tests/Scripts/run-dmg-packaging-tests.sh
if [[ -d .github/workflows ]]; then
command -v actionlint >/dev/null || {
diff --git a/scripts/embed-command-products.sh b/scripts/embed-command-products.sh
new file mode 100755
index 0000000..ba7d9d1
--- /dev/null
+++ b/scripts/embed-command-products.sh
@@ -0,0 +1,25 @@
+#!/bin/bash
+set -euo pipefail
+
+repository_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+readonly repository_root
+# shellcheck disable=SC1091
+source "$repository_root/scripts/lib/dmg-packaging.sh"
+
+if [[ "$#" != "3" ]]; then
+ echo "usage: scripts/embed-command-products.sh APP CLI HELPER" >&2
+ exit 64
+fi
+
+app="$1"
+cli="$2"
+helper="$3"
+
+verify_app_layout "$app"
+verify_universal_executable "CLI payload" "$cli"
+verify_universal_executable "helper payload" "$helper"
+
+ditto "$cli" "$app/Contents/MacOS/clamshellctl"
+ditto "$helper" "$app/Contents/Resources/clamshellctl-helper"
+sign_app_bundle "$app"
+verify_packaged_app "$app"
diff --git a/scripts/lib/dmg-packaging.sh b/scripts/lib/dmg-packaging.sh
new file mode 100644
index 0000000..aaa029e
--- /dev/null
+++ b/scripts/lib/dmg-packaging.sh
@@ -0,0 +1,271 @@
+#!/bin/bash
+
+dmg_error() {
+ echo "error: $*" >&2
+ return 1
+}
+
+validate_release_version() {
+ local version="$1"
+
+ [[ "$version" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] ||
+ dmg_error "version must use MAJOR.MINOR.PATCH without a v prefix: $version"
+}
+
+require_regular_file() {
+ local description="$1"
+ local path="$2"
+
+ [[ -f "$path" && ! -L "$path" ]] ||
+ dmg_error "$description is not a regular file: $path"
+}
+
+require_directory() {
+ local description="$1"
+ local path="$2"
+
+ [[ -d "$path" && ! -L "$path" ]] ||
+ dmg_error "$description is missing: $path"
+}
+
+verify_universal_executable() {
+ local description="$1"
+ local path="$2"
+
+ require_regular_file "$description" "$path" || return
+ lipo "$path" -verify_arch arm64 x86_64 >/dev/null 2>&1 ||
+ dmg_error "$description must contain arm64 and x86_64: $path"
+}
+
+is_ad_hoc_signed() {
+ local path="$1"
+ local details
+
+ details="$(codesign --display --verbose=4 "$path" 2>&1)" || return
+ [[ "$details" == *"Signature=adhoc"* ]]
+}
+
+verify_ad_hoc_signature() {
+ local description="$1"
+ local path="$2"
+
+ codesign --verify --strict "$path" >/dev/null 2>&1 ||
+ dmg_error "$description has an invalid signature: $path" || return
+ is_ad_hoc_signed "$path" ||
+ dmg_error "$description must use an ad hoc signature: $path"
+}
+
+verify_app_layout() {
+ local app="$1"
+
+ require_directory "app bundle" "$app" || return
+ require_directory \
+ "control extension" \
+ "$app/Contents/PlugIns/ClamshellControl.appex" || return
+ verify_universal_executable \
+ "app executable" \
+ "$app/Contents/MacOS/Clamshell" || return
+ verify_universal_executable \
+ "control extension executable" \
+ "$app/Contents/PlugIns/ClamshellControl.appex/Contents/MacOS/ClamshellControl"
+}
+
+verify_embedded_payloads() {
+ local app="$1"
+
+ verify_universal_executable \
+ "CLI payload" \
+ "$app/Contents/MacOS/clamshellctl" || return
+ verify_universal_executable \
+ "helper payload" \
+ "$app/Contents/Resources/clamshellctl-helper"
+}
+
+sign_app_bundle() {
+ local app="$1"
+ local extension="$app/Contents/PlugIns/ClamshellControl.appex"
+ local cli="$app/Contents/MacOS/clamshellctl"
+ local helper="$app/Contents/Resources/clamshellctl-helper"
+
+ verify_app_layout "$app" || return
+ verify_embedded_payloads "$app" || return
+
+ chmod 0755 "$cli" "$helper" ||
+ dmg_error "could not make command payloads executable" || return
+ codesign --force --sign - "$cli" >/dev/null ||
+ dmg_error "could not sign CLI payload: $cli" || return
+ codesign --force --sign - "$helper" >/dev/null ||
+ dmg_error "could not sign helper payload: $helper" || return
+ codesign \
+ --force \
+ --sign - \
+ --preserve-metadata=entitlements,requirements,flags \
+ "$extension" >/dev/null ||
+ dmg_error "could not sign control extension: $extension" || return
+ codesign \
+ --force \
+ --sign - \
+ --preserve-metadata=entitlements,requirements,flags \
+ "$app" >/dev/null ||
+ dmg_error "could not sign app bundle: $app"
+}
+
+verify_packaged_app() {
+ local app="$1"
+ local extension="$app/Contents/PlugIns/ClamshellControl.appex"
+
+ verify_app_layout "$app" || return
+ verify_embedded_payloads "$app" || return
+ verify_ad_hoc_signature "CLI payload" "$app/Contents/MacOS/clamshellctl" || return
+ verify_ad_hoc_signature \
+ "helper payload" \
+ "$app/Contents/Resources/clamshellctl-helper" || return
+ verify_ad_hoc_signature "control extension" "$extension" || return
+ verify_ad_hoc_signature "app" "$app" || return
+ codesign --verify --deep --strict "$app" >/dev/null 2>&1 ||
+ dmg_error "app has an invalid nested signature: $app"
+}
+
+verify_bundle_versions() {
+ local version="$1"
+ local app="$2"
+ local app_version
+ local extension_version
+
+ app_version="$(
+ /usr/libexec/PlistBuddy \
+ -c 'Print :CFBundleShortVersionString' \
+ "$app/Contents/Info.plist"
+ )" || dmg_error "app bundle version is missing" || return
+ extension_version="$(
+ /usr/libexec/PlistBuddy \
+ -c 'Print :CFBundleShortVersionString' \
+ "$app/Contents/PlugIns/ClamshellControl.appex/Contents/Info.plist"
+ )" || dmg_error "control extension version is missing" || return
+
+ [[ "$app_version" == "$version" ]] ||
+ dmg_error "app bundle version must be $version: $app_version" || return
+ [[ "$extension_version" == "$version" ]] ||
+ dmg_error "control extension version must be $version: $extension_version"
+}
+
+resolve_release_dmg_path() {
+ local version="$1"
+ local output_directory="$2"
+
+ validate_release_version "$version" || return
+ mkdir -p "$output_directory"
+ [[ -d "$output_directory" && ! -L "$output_directory" ]] ||
+ dmg_error "output directory must be a real directory: $output_directory" || return
+
+ local physical_output_directory
+ physical_output_directory="$(cd "$output_directory" && pwd -P)"
+ local dmg="$physical_output_directory/clamshellctl-v$version.dmg"
+ local checksum="$dmg.sha256"
+
+ [[ ! -L "$dmg" && ! -L "$checksum" ]] ||
+ dmg_error "output path must stay inside the output directory" || return
+ printf '%s\n' "$dmg"
+}
+
+verify_dmg_contents() {
+ local dmg="$1"
+ local mount_point
+ mount_point="$(mktemp -d "${TMPDIR:-/tmp}/clamshellctl-dmg-mount.XXXXXX")" ||
+ dmg_error "could not create DMG verification directory" || return
+
+ if ! hdiutil attach \
+ -readonly \
+ -nobrowse \
+ -mountpoint "$mount_point" \
+ "$dmg" >/dev/null 2>&1; then
+ rmdir "$mount_point"
+ dmg_error "could not mount DMG for verification: $dmg"
+ return
+ fi
+
+ local status=0
+ [[ -d "$mount_point/Clamshell.app" ]] || status=1
+ [[ -L "$mount_point/Applications" ]] || status=1
+ [[ "$(readlink "$mount_point/Applications" 2>/dev/null || true)" == "/Applications" ]] ||
+ status=1
+ local entry_count
+ entry_count="$(find "$mount_point" -mindepth 1 -maxdepth 1 | wc -l | tr -d ' ')"
+ [[ "$entry_count" == "2" ]] || status=1
+
+ if ! hdiutil detach "$mount_point" >/dev/null 2>&1 &&
+ ! hdiutil detach -force "$mount_point" >/dev/null 2>&1; then
+ dmg_error "could not detach DMG after verification: $dmg"
+ return
+ fi
+ rmdir "$mount_point" ||
+ dmg_error "could not remove DMG verification directory: $mount_point" || return
+ [[ "$status" == "0" ]] || dmg_error "DMG must contain only Clamshell.app and Applications"
+}
+
+create_release_dmg() {
+ local version="$1"
+ local output_directory="$2"
+ local app="$3"
+
+ local release_dmg
+ release_dmg="$(resolve_release_dmg_path "$version" "$output_directory")" || return
+ local release_checksum
+ release_checksum="$release_dmg.sha256"
+ verify_packaged_app "$app" || return
+ verify_bundle_versions "$version" "$app" || return
+
+ local staging_directory
+ staging_directory="$(mktemp -d "${TMPDIR:-/tmp}/clamshellctl-dmg-stage.XXXXXX")" ||
+ dmg_error "could not create DMG staging directory" || return
+ if ! ditto "$app" "$staging_directory/Clamshell.app"; then
+ rm -rf "$staging_directory"
+ dmg_error "could not stage app bundle"
+ return
+ fi
+ if ! ln -s /Applications "$staging_directory/Applications"; then
+ rm -rf "$staging_directory"
+ dmg_error "could not stage Applications symlink"
+ return
+ fi
+
+ local temporary_dmg
+ temporary_dmg="$(dirname "$release_dmg")/.clamshellctl-v$version.partial.dmg"
+ if ! hdiutil create \
+ -quiet \
+ -volname "Clamshell" \
+ -srcfolder "$staging_directory" \
+ -format UDZO \
+ -imagekey zlib-level=9 \
+ "$temporary_dmg"; then
+ rm -rf "$staging_directory" "$temporary_dmg"
+ dmg_error "could not create release DMG"
+ return
+ fi
+ rm -rf "$staging_directory"
+
+ if ! mv "$temporary_dmg" "$release_dmg"; then
+ rm -f "$temporary_dmg"
+ dmg_error "could not move release DMG into the output directory"
+ return
+ fi
+ if ! hdiutil verify "$release_dmg" >/dev/null; then
+ rm -f "$release_dmg"
+ dmg_error "release DMG checksum is invalid: $release_dmg"
+ return
+ fi
+ if ! verify_dmg_contents "$release_dmg"; then
+ rm -f "$release_dmg"
+ return
+ fi
+
+ if ! (
+ cd "$(dirname "$release_dmg")" || exit
+ shasum -a 256 "$(basename "$release_dmg")" >"$(basename "$release_checksum")"
+ shasum -a 256 -c "$(basename "$release_checksum")" >/dev/null
+ ); then
+ rm -f "$release_checksum"
+ dmg_error "could not write release checksum: $release_checksum"
+ return
+ fi
+}
diff --git a/scripts/package-dmg.sh b/scripts/package-dmg.sh
new file mode 100755
index 0000000..16822c0
--- /dev/null
+++ b/scripts/package-dmg.sh
@@ -0,0 +1,72 @@
+#!/bin/bash
+set -euo pipefail
+
+repository_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+readonly repository_root
+# shellcheck disable=SC1091
+source "$repository_root/scripts/lib/dmg-packaging.sh"
+
+if [[ "$#" != "2" ]]; then
+ echo "usage: scripts/package-dmg.sh VERSION OUTPUT_DIRECTORY" >&2
+ exit 64
+fi
+
+version="$1"
+output_directory="$2"
+validate_release_version "$version"
+
+for command in codesign ditto hdiutil lipo shasum swift xcodebuild xcodegen; do
+ command -v "$command" >/dev/null || {
+ echo "error: required command is unavailable: $command" >&2
+ exit 1
+ }
+done
+
+build_root="$repository_root/.build/package-dmg"
+swift_build_path="$build_root/swift"
+derived_data_path="$build_root/DerivedData"
+assembled_app="$build_root/Clamshell.app"
+mkdir -p "$build_root"
+
+swift_build_arguments=(
+ --package-path "$repository_root"
+ --scratch-path "$swift_build_path"
+ --configuration release
+ --arch arm64
+ --arch x86_64
+)
+
+swift build "${swift_build_arguments[@]}" --product clamshellctl
+swift build "${swift_build_arguments[@]}" --product clamshellctl-helper
+
+swift_bin_path="$(
+ swift build "${swift_build_arguments[@]}" --show-bin-path
+)"
+
+(
+ cd "$repository_root"
+ xcodegen generate
+ xcodebuild \
+ -project Clamshell.xcodeproj \
+ -scheme ClamshellApp \
+ -configuration Release \
+ -derivedDataPath "$derived_data_path" \
+ ARCHS="arm64 x86_64" \
+ ONLY_ACTIVE_ARCH=NO \
+ CODE_SIGN_STYLE=Manual \
+ CODE_SIGN_IDENTITY=- \
+ DEVELOPMENT_TEAM= \
+ MARKETING_VERSION="$version" \
+ build
+)
+
+rm -rf "$assembled_app"
+ditto "$derived_data_path/Build/Products/Release/Clamshell.app" "$assembled_app"
+bash "$repository_root/scripts/embed-command-products.sh" \
+ "$assembled_app" \
+ "$swift_bin_path/clamshellctl" \
+ "$swift_bin_path/clamshellctl-helper"
+create_release_dmg "$version" "$output_directory" "$assembled_app"
+
+echo "Created $output_directory/clamshellctl-v$version.dmg"
+echo "Created $output_directory/clamshellctl-v$version.dmg.sha256"