#!/usr/bin/env bash # Builds, signs and ships MeshCore SAR for macOS and iOS. # # ./tool/release.sh macos # → dist/meshcore-sar-v-macos.dmg # ./tool/release.sh ios # → TestFlight, through the lanes in ios/fastlane # ./tool/release.sh all # ./tool/release.sh macos --dry-run # resolve credentials + version, build nothing # # Credentials come from the environment, so the same script runs on a laptop and # in CI with nothing changed. Locally, put them in tool/.release.env (gitignored, # sourced automatically); in GitHub Actions they arrive as repository secrets. # # MACOS_CERT_P12 base64 of the "Developer ID Application" .p12 # MACOS_CERT_PASSWORD password for that .p12 # MACOS_SIGN_ID e.g. "Developer ID Application: Name (JND55328G8)" # IOS_CERT_P12 base64 of the "Apple Distribution" .p12 # IOS_CERT_PASSWORD password for that .p12 # ASC_KEY_ID App Store Connect API key id # ASC_ISSUER_ID App Store Connect issuer id # ASC_KEY_P8 base64 of the AuthKey_.p8 # APPLE_TEAM_ID optional, defaults to JND55328G8 # IOS_PROFILE optional base64 .mobileprovision; without it the API # key fetches the profile via -allowProvisioningUpdates # # One App Store Connect key covers both halves: notarytool notarizes the DMG # with it and altool uploads the IPA with it. The certificates never touch the # login keychain — they are imported into a throwaway keychain that the exit # trap deletes along with the decoded private key, whether the build succeeds or # fails. set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" APP_DIR="$ROOT_DIR" DIST="$APP_DIR/dist" ENV_FILE="$APP_DIR/tool/.release.env" say() { printf '→ %s\n' "$*"; } die() { printf 'error: %s\n' "$*" >&2; exit 1; } # ---------------------------------------------------------------- arguments -- COMMAND="" VERSION="" BUILD_NUMBER="" DRY_RUN=0 usage() { sed -n '2,31p' "${BASH_SOURCE[0]}" | sed 's/^#\{1\} \{0,1\}//' exit "${1:-0}" } [ $# -gt 0 ] || usage 1 case "$1" in macos|ios|all) COMMAND="$1"; shift ;; -h|--help) usage ;; *) die "unknown command '$1' (expected macos, ios or all)" ;; esac while [ $# -gt 0 ]; do case "$1" in --version) VERSION="${2:-}"; shift 2 ;; --build) BUILD_NUMBER="${2:-}"; shift 2 ;; --dry-run) DRY_RUN=1; shift ;; -h|--help) usage ;; *) die "unknown option '$1'" ;; esac done # -------------------------------------------------------------- credentials -- if [ -f "$ENV_FILE" ]; then say "credentials from tool/.release.env" set -a; # shellcheck disable=SC1090 . "$ENV_FILE"; set +a fi APPLE_TEAM_ID="${APPLE_TEAM_ID:-JND55328G8}" # Names every missing variable at once — a build that dies on the fourth secret # after twelve minutes of compiling is a waste of a coffee break. require() { local missing=() for name in "$@"; do [ -n "${!name:-}" ] || missing+=("$name") done if [ ${#missing[@]} -gt 0 ]; then printf 'error: missing credentials: %s\n' "${missing[*]}" >&2 printf ' set them in %s or in the environment\n' "${ENV_FILE#"$ROOT_DIR"/}" >&2 exit 1 fi } case "$COMMAND" in macos) require MACOS_CERT_P12 MACOS_CERT_PASSWORD MACOS_SIGN_ID ASC_KEY_ID ASC_ISSUER_ID ASC_KEY_P8 ;; ios) require IOS_CERT_P12 IOS_CERT_PASSWORD ASC_KEY_ID ASC_ISSUER_ID ASC_KEY_P8 ;; all) require MACOS_CERT_P12 MACOS_CERT_PASSWORD MACOS_SIGN_ID \ IOS_CERT_P12 IOS_CERT_PASSWORD ASC_KEY_ID ASC_ISSUER_ID ASC_KEY_P8 ;; esac # ------------------------------------------------------------------ version -- # pubspec.yaml carries the version `make bump` wrote. Its +build is a starting # floor only: the iOS lane asks TestFlight what it has already accepted and # takes whichever number is higher, so a release never needs a bump first. if [ -z "$VERSION" ]; then VERSION="$(awk -F'[ +]' '/^version:/ {print $2}' "$APP_DIR/pubspec.yaml")" fi if [ -z "$BUILD_NUMBER" ]; then BUILD_NUMBER="$(awk -F'+' '/^version:/ {print $2}' "$APP_DIR/pubspec.yaml")" fi [ -n "$BUILD_NUMBER" ] || BUILD_NUMBER=0 # ------------------------------------------------------------------ flutter -- # mise.toml pins the SDK this project builds with; a bare `flutter` on PATH is # some other version. cd "$APP_DIR" if command -v mise >/dev/null 2>&1 && [ -f "$ROOT_DIR/mise.toml" ]; then flutter() { mise exec -- flutter "$@"; } else command -v flutter >/dev/null 2>&1 || die "flutter not found (install mise, or put flutter on PATH)" fi # ------------------------------------------------------- keychain + api key -- KEYCHAIN="" WORK="" KEYCHAINS_BEFORE="" cleanup() { if [ -n "$KEYCHAIN" ]; then security delete-keychain "$KEYCHAIN" 2>/dev/null || true # Putting the search list back matters on a laptop, where the list the # build borrowed is the one the rest of the session depends on. if [ -n "$KEYCHAINS_BEFORE" ]; then # shellcheck disable=SC2086 security list-keychains -d user -s $KEYCHAINS_BEFORE 2>/dev/null || true fi fi [ -n "$WORK" ] && rm -rf "$WORK" || true } trap cleanup EXIT # Imports a .p12 into a keychain created for this run only. The partition list # is what stops codesign from popping a UI prompt on a headless runner. open_keychain() { [ -n "$KEYCHAIN" ] && return 0 KEYCHAIN="meshcore-release.keychain" local pw; pw="$(uuidgen)" security delete-keychain "$KEYCHAIN" 2>/dev/null || true security create-keychain -p "$pw" "$KEYCHAIN" security set-keychain-settings -lut 21600 "$KEYCHAIN" security unlock-keychain -p "$pw" "$KEYCHAIN" KEYCHAINS_BEFORE="$(security list-keychains -d user | tr -d '"' | tr '\n' ' ')" # shellcheck disable=SC2086 security list-keychains -d user -s "$KEYCHAIN" $KEYCHAINS_BEFORE KEYCHAIN_PW="$pw" } import_cert() { local b64="$1" password="$2" label="$3" open_keychain printf '%s' "$b64" | base64 --decode > "$WORK/cert.p12" security import "$WORK/cert.p12" -k "$KEYCHAIN" -P "$password" \ -T /usr/bin/codesign -T /usr/bin/security >/dev/null security set-key-partition-list -S apple-tool:,apple:,codesign: \ -s -k "$KEYCHAIN_PW" "$KEYCHAIN" >/dev/null rm -f "$WORK/cert.p12" say "imported $label certificate" } WORK="$(mktemp -d -t meshcore-release)" KEY_FILE="$WORK/AuthKey_${ASC_KEY_ID}.p8" printf '%s' "$ASC_KEY_P8" | base64 --decode > "$KEY_FILE" chmod 600 "$KEY_FILE" # altool looks the key up by id in a directory; notarytool takes the path. export API_PRIVATE_KEYS_DIR="$WORK" # -------------------------------------------------------------------- macos -- build_macos() { say "macOS $VERSION ($BUILD_NUMBER)" import_cert "$MACOS_CERT_P12" "$MACOS_CERT_PASSWORD" "Developer ID" flutter build macos --release \ --build-name="$VERSION" --build-number="$BUILD_NUMBER" local app app="$(find "$APP_DIR/build/macos/Build/Products/Release" -maxdepth 1 -name '*.app' | head -1)" [ -n "$app" ] || die "no .app in build/macos/Build/Products/Release" # Sign inside out: nested code first, then the bundle. --force drops the # entitlements Xcode baked in, so hand them back on the outer signature or the # sandboxed app launches without network access. say "signing $(basename "$app")" while IFS= read -r nested; do codesign --force --options runtime --timestamp \ --sign "$MACOS_SIGN_ID" "$nested" done < <(find "$app/Contents" -depth \( -name '*.framework' -o -name '*.dylib' \) -print 2>/dev/null) codesign --force --options runtime --timestamp \ --entitlements "$APP_DIR/macos/Runner/Release.entitlements" \ --sign "$MACOS_SIGN_ID" "$app" codesign --verify --strict --verbose=2 "$app" mkdir -p "$DIST" local dmg="$DIST/meshcore-sar-v$VERSION-macos.dmg" say "packaging $(basename "$dmg")" rm -f "$dmg" hdiutil create -volname "MeshCore SAR" -srcfolder "$app" -ov -format UDZO "$dmg" >/dev/null say "notarizing (this waits on Apple)" xcrun notarytool submit "$dmg" \ --key "$KEY_FILE" --key-id "$ASC_KEY_ID" --issuer "$ASC_ISSUER_ID" --wait xcrun stapler staple "$app" xcrun stapler staple "$dmg" xcrun stapler validate "$dmg" spctl --assess --type exec -vv "$app" say "done: ${dmg#"$ROOT_DIR"/}" } # ---------------------------------------------------------------------- ios -- build_ios() { say "iOS $VERSION ($BUILD_NUMBER)" import_cert "$IOS_CERT_P12" "$IOS_CERT_PASSWORD" "Apple Distribution" if [ -n "${IOS_PROFILE:-}" ]; then local dir="$HOME/Library/MobileDevice/Provisioning Profiles" mkdir -p "$dir" printf '%s' "$IOS_PROFILE" | base64 --decode > "$dir/meshcore-release.mobileprovision" say "installed provisioning profile" fi # --config-only just refreshes Generated.xcconfig; the Flutter build itself # happens inside the Xcode build phase when fastlane archives. This is the # sequence `make release-ios` has always used. flutter build ios --release --no-codesign --config-only \ --build-name="$VERSION" --build-number="$BUILD_NUMBER" [ -f "$APP_DIR/ios/Gemfile.lock" ] || die "run 'bundle install' in ios/ first" say "archiving and uploading through the ios/fastlane lanes" mkdir -p "$DIST" # fastlane refuses to handle non-ASCII metadata without a UTF-8 locale, and # a CI runner's locale is whatever the image felt like. export LC_ALL=en_US.UTF-8 LANG=en_US.UTF-8 export FASTLANE_SKIP_UPDATE_CHECK=1 export ASC_KEY_ID ASC_ISSUER_ID ASC_KEY_P8 APPLE_TEAM_ID export RELEASE_VERSION="$VERSION" RELEASE_BUILD="$BUILD_NUMBER" (cd "$APP_DIR/ios" && bundle exec fastlane ios release) say "done: $VERSION ($BUILD_NUMBER) is on TestFlight" } # --------------------------------------------------------------------- main -- if [ "$DRY_RUN" -eq 1 ]; then if [ -n "${IOS_PROFILE:-}" ]; then profile_note="supplied"; else profile_note="fetched with -allowProvisioningUpdates"; fi cat <