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"