From a7136abc6ceb37d3eb354e7647ec4ce25e6d7e01 Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Sun, 20 Sep 2026 18:02:24 +0800 Subject: chore(macos): add self-contained packaging script [Change Nature] This commit adds release packaging maintenance for the macOS test build. [Maintenance Work] - Publish osx-arm64 and osx-x64 as self-contained applications. - Bundle launchers, Info.plist, native dependencies, verification evidence, and ZIP checksums. - Document unsigned, unnotarized usage and per-application Gatekeeper handling. [Implementation] - Reject missing apphosts or incompatible Mach-O/native dylib architectures. - Generate reproducible timestamped release directories without overwriting prior output. - Create DMGs only when hdiutil is available on the build host. [Impact] - Adds scripts/macos-package.sh only. - Does not modify game source, M3 acceptance state, logs, or existing worktree changes. --- scripts/macos-package.sh | 339 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 339 insertions(+) create mode 100755 scripts/macos-package.sh diff --git a/scripts/macos-package.sh b/scripts/macos-package.sh new file mode 100755 index 0000000..10c80b8 --- /dev/null +++ b/scripts/macos-package.sh @@ -0,0 +1,339 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +export LC_ALL=C + +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)" +PROJECT="$REPO_ROOT/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj" +STAMP="${MACOS_TIMESTAMP:-$(date -u +%Y%m%dT%H%M%SZ)}" +OUTPUT_ROOT="$REPO_ROOT/artifacts/releases/macos/$STAMP" +VERIFICATION_ROOT="$OUTPUT_ROOT/verification" +SOURCE_COMMIT="$(git -C "$REPO_ROOT" rev-parse HEAD 2>/dev/null || printf 'unknown')" + +if [[ -e "$OUTPUT_ROOT" ]]; then + printf 'Refusing to overwrite existing macOS release directory: %s\n' "$OUTPUT_ROOT" >&2 + exit 2 +fi + +for command_name in dotnet file zip unzip; do + if ! command -v "$command_name" >/dev/null 2>&1; then + printf 'Required command is missing: %s\n' "$command_name" >&2 + exit 127 + fi +done + +if [[ ! -f "$PROJECT" ]]; then + printf 'Desktop project not found: %s\n' "$PROJECT" >&2 + exit 2 +fi + +mkdir -p "$VERIFICATION_ROOT" + +file_size() { + if stat -c '%s' "$1" >/dev/null 2>&1; then + stat -c '%s' "$1" + else + stat -f '%z' "$1" + fi +} + +sha256() { + local output + if command -v sha256sum >/dev/null 2>&1; then + output="$(sha256sum "$1")" + else + output="$(shasum -a 256 "$1")" + fi + printf '%s\n' "${output%% *}" +} + +write_launcher() { + local launcher_path="$1" + cat > "$launcher_path" <<'EOF' +#!/bin/sh +set -eu + +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +BUNDLE_DIR="$(CDPATH= cd -- "$SCRIPT_DIR/../.." && pwd)" + +if [ -z "${LV_SAVE_PATH:-}" ]; then + if [ -n "${HOME:-}" ]; then + SAVE_DIR="$HOME/Library/Application Support/Living Village" + mkdir -p "$SAVE_DIR" + export LV_SAVE_PATH="$SAVE_DIR/living-village.save" + else + export LV_SAVE_PATH="$BUNDLE_DIR/living-village.save" + fi +elif [ "${LV_SAVE_PATH#/}" = "$LV_SAVE_PATH" ]; then + export LV_SAVE_PATH="$BUNDLE_DIR/$LV_SAVE_PATH" +fi + +export DYLD_LIBRARY_PATH="$SCRIPT_DIR${DYLD_LIBRARY_PATH:+:$DYLD_LIBRARY_PATH}" +cd "$SCRIPT_DIR" +exec "$SCRIPT_DIR/LivingVillage.Desktop" "$@" +EOF + chmod +x "$launcher_path" +} + +write_info_plist() { + local plist_path="$1" + local rid="$2" + local bundle_name="$3" + local architecture="$4" + cat > "$plist_path" < + + + + CFBundleDisplayName + Living Village 测试版 + CFBundleExecutable + LivingVillage + CFBundleIdentifier + com.livingvillage.desktop.test + CFBundleName + Living Village + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.1.0-test + CFBundleVersion + $STAMP + CFBundleSupportedPlatforms + + MacOSX + + LSMinimumSystemVersion + 11.0 + LSArchitecturePriority + + $architecture + + NSHighResolutionCapable + + + +EOF + printf '%s\n' "bundle=$bundle_name" "rid=$rid" > /dev/null +} + +write_package_info() { + local info_path="$1" + local rid="$2" + local bundle_name="$3" + cat > "$info_path" < "$OUTPUT_ROOT/README.md" <&2 + exit 2 + ;; + esac + + app_name="LivingVillage-$rid.app" + app_dir="$OUTPUT_ROOT/$app_name" + work_dir="$OUTPUT_ROOT/.work/$rid" + publish_dir="$work_dir/publish" + publish_log="$VERIFICATION_ROOT/publish-$rid.log" + mach_o_report="$VERIFICATION_ROOT/mach-o-$rid.txt" + zip_path="$OUTPUT_ROOT/LivingVillage-$rid.zip" + + mkdir -p "$publish_dir" + printf 'Publishing %s self-contained...\n' "$rid" + if ! dotnet publish "$PROJECT" \ + --configuration Release \ + --runtime "$rid" \ + --self-contained true \ + --output "$publish_dir" \ + --nologo \ + -p:UseAppHost=true \ + -p:DebugType=None \ + -p:DebugSymbols=false \ + -p:PublishSingleFile=false \ + > "$publish_log" 2>&1; then + printf 'BLOCKED: dotnet publish failed for %s; see %s\n' "$rid" "$publish_log" >&2 + exit 1 + fi + + executable="$publish_dir/LivingVillage.Desktop" + if [[ ! -f "$executable" ]]; then + printf 'BLOCKED: self-contained apphost missing for %s: %s\n' "$rid" "$executable" >&2 + exit 1 + fi + chmod +x "$executable" + + executable_info="$(file -b "$executable")" + printf 'executable=%s\n' "$executable_info" > "$mach_o_report" + if [[ "$executable_info" != *"Mach-O"* || "$executable_info" != *"$architecture"* ]]; then + printf 'BLOCKED: apphost for %s is not the expected Mach-O architecture: %s\n' "$rid" "$executable_info" >&2 + exit 1 + fi + + for native_name in libSDL2-2.0.0.dylib libopenal.dylib; do + native_path="$publish_dir/$native_name" + if [[ ! -f "$native_path" ]]; then + printf 'BLOCKED: required MonoGame native dependency is missing for %s: %s\n' "$rid" "$native_name" >&2 + exit 1 + fi + native_info="$(file -b "$native_path")" + printf '%s=%s\n' "$native_name" "$native_info" >> "$mach_o_report" + if [[ "$native_info" != *"Mach-O"* || "$native_info" != *"arm64"* || "$native_info" != *"x86_64"* ]]; then + printf 'BLOCKED: MonoGame native dependency lacks universal arm64+x86_64 Mach-O support: %s (%s)\n' "$native_path" "$native_info" >&2 + exit 1 + fi + done + + shopt -s nullglob + dylibs=("$publish_dir"/*.dylib) + shopt -u nullglob + if [[ "${#dylibs[@]}" -eq 0 ]]; then + printf 'BLOCKED: no macOS native dylib was published for %s\n' "$rid" >&2 + exit 1 + fi + for native_path in "${dylibs[@]}"; do + native_info="$(file -b "$native_path")" + printf 'native:%s=%s\n' "$(basename "$native_path")" "$native_info" >> "$mach_o_report" + if [[ "$native_info" != *"Mach-O"* || "$native_info" != *"$architecture"* ]]; then + printf 'BLOCKED: published native dylib has the wrong architecture for %s: %s (%s)\n' "$rid" "$native_path" "$native_info" >&2 + exit 1 + fi + done + + mkdir -p "$app_dir/Contents/MacOS" "$app_dir/Contents/Resources" + cp -R "$publish_dir"/. "$app_dir/Contents/MacOS/" + write_launcher "$app_dir/Contents/MacOS/LivingVillage" + write_info_plist "$app_dir/Contents/Info.plist" "$rid" "$app_name" "$architecture" + write_package_info "$app_dir/Contents/Resources/PACKAGE-INFO.txt" "$rid" "$app_name" + cp "$OUTPUT_ROOT/README.md" "$app_dir/Contents/Resources/README.md" + cp "$REPO_ROOT/docs/assets-and-licenses.md" "$app_dir/Contents/Resources/ASSETS-AND-LICENSES.md" + chmod +x "$app_dir/Contents/MacOS/LivingVillage" "$app_dir/Contents/MacOS/LivingVillage.Desktop" + + if ! file -b "$app_dir/Contents/MacOS/LivingVillage.Desktop" >> "$mach_o_report"; then + printf 'BLOCKED: failed to inspect bundled apphost for %s\n' "$rid" >&2 + exit 1 + fi + + package_info="$OUTPUT_ROOT/$app_name/Contents/Resources/PACKAGE-INFO.txt" + cp "$package_info" "$OUTPUT_ROOT/PACKAGE-INFO-$rid.txt" + + printf 'Zipping %s...\n' "$app_name" + ( + cd "$OUTPUT_ROOT" + zip -q -r -X -y "$zip_path" "$app_name" + ) + if ! unzip -tqq "$zip_path" > "$VERIFICATION_ROOT/zip-$rid.txt" 2>&1; then + printf 'BLOCKED: ZIP integrity check failed: %s\n' "$zip_path" >&2 + exit 1 + fi + ZIP_PATHS+=("$zip_path") + + dmg_path="$OUTPUT_ROOT/LivingVillage-$rid.dmg" + if command -v hdiutil >/dev/null 2>&1; then + if hdiutil create -quiet -volname "Living Village $rid" -srcfolder "$app_dir" -ov -format UDZO "$dmg_path"; then + DMG_STATUS+=("$rid=created:$dmg_path") + else + rm -f "$dmg_path" + DMG_STATUS+=("$rid=not-created:hdiutil-failed") + fi + else + DMG_STATUS+=("$rid=not-created:hdiutil-unavailable-on-build-host") + fi +done + +{ + printf 'path bytes sha256\n' + for zip_path in "${ZIP_PATHS[@]}"; do + printf '%s %s %s\n' \ + "${zip_path#"$OUTPUT_ROOT"/}" \ + "$(file_size "$zip_path")" \ + "$(sha256 "$zip_path")" + done +} > "$OUTPUT_ROOT/SHA256SUMS.txt" + +{ + printf 'release_root=%s\n' "$OUTPUT_ROOT" + printf 'source_commit=%s\n' "$SOURCE_COMMIT" + printf 'runtime_mode=self-contained\n' + printf 'signed=false\n' + printf 'notarized=false\n' + printf 'macos_hardware_verification=false\n' + for status in "${DMG_STATUS[@]}"; do + printf 'dmg_%s\n' "$status" + done +} > "$OUTPUT_ROOT/PACKAGE-INFO.txt" + +rm -rf "$OUTPUT_ROOT/.work" + +printf 'MACOS_RELEASE_ROOT=%s\n' "$OUTPUT_ROOT" +printf 'MACOS_SHA256_FILE=%s\n' "$OUTPUT_ROOT/SHA256SUMS.txt" +for zip_path in "${ZIP_PATHS[@]}"; do + printf 'MACOS_ZIP=%s bytes=%s sha256=%s\n' \ + "$zip_path" \ + "$(file_size "$zip_path")" \ + "$(sha256 "$zip_path")" +done +for status in "${DMG_STATUS[@]}"; do + printf 'MACOS_DMG=%s\n' "$status" +done -- cgit v1.2.3