From de321be0e546d36ae8874b6acd87d7acf4b70f2c Mon Sep 17 00:00:00 2001 From: Janez Troha Date: Fri, 18 Sep 2026 10:38:41 +0200 Subject: [PATCH] feat: signed macOS and iOS releases from one credentials-driven script --- .github/workflows/build-artifacts.yml | 37 ++++ .gitignore | 7 + ios/Gemfile | 5 +- ios/Gemfile.lock | 239 ++++++++++++---------- ios/fastlane/Fastfile | 78 +++++++- tool/release.sh | 277 ++++++++++++++++++++++++++ tool/secrets.sh | 266 +++++++++++++++++++++++++ 7 files changed, 802 insertions(+), 107 deletions(-) create mode 100755 tool/release.sh create mode 100755 tool/secrets.sh diff --git a/.github/workflows/build-artifacts.yml b/.github/workflows/build-artifacts.yml index 18c283f..4b17a6e 100644 --- a/.github/workflows/build-artifacts.yml +++ b/.github/workflows/build-artifacts.yml @@ -261,7 +261,23 @@ jobs: - name: Build macOS release run: flutter build macos --release + # A release gets the signed, notarized DMG; an ordinary push keeps the + # quick unsigned one, because notarization means waiting on Apple. + - name: Build, sign and notarize DMG + if: github.event_name == 'release' + env: + MACOS_CERT_P12: ${{ secrets.MACOS_CERT_P12 }} + MACOS_CERT_PASSWORD: ${{ secrets.MACOS_CERT_PASSWORD }} + MACOS_SIGN_ID: ${{ secrets.MACOS_SIGN_ID }} + ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} + ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} + ASC_KEY_P8: ${{ secrets.ASC_KEY_P8 }} + run: | + ./tool/release.sh macos + cp dist/*-macos.dmg . + - name: Create DMG + if: github.event_name != 'release' run: | RAW_TAG="${{ github.event.release.tag_name || github.ref_name }}" TAG="${RAW_TAG//\//-}" @@ -329,6 +345,27 @@ jobs: echo "IOS_RUNNER_ZIP=${RUNNER_ZIP_NAME}" >> "$GITHUB_ENV" ls -lah "${IPA_NAME}" "${RUNNER_ZIP_NAME}" + # The unsigned artifacts above are for anyone who wants the binary. On a + # release the same commit is signed and sent to TestFlight, through the + # ios/fastlane lanes `make release-ios` uses. + - name: Set up Ruby + if: github.event_name == 'release' + uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.3" + working-directory: ios + bundler-cache: true + + - name: Sign and upload to TestFlight + if: github.event_name == 'release' + env: + IOS_CERT_P12: ${{ secrets.IOS_CERT_P12 }} + IOS_CERT_PASSWORD: ${{ secrets.IOS_CERT_PASSWORD }} + ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} + ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} + ASC_KEY_P8: ${{ secrets.ASC_KEY_P8 }} + run: ./tool/release.sh ios + - name: Upload iOS artifact uses: actions/upload-artifact@v4 with: diff --git a/.gitignore b/.gitignore index b266df0..53f8072 100644 --- a/.gitignore +++ b/.gitignore @@ -97,3 +97,10 @@ worker/dist/ # Dart code coverage coverage/ lcov.info + +# Release tooling (tool/release.sh, tool/secrets.sh) +dist/ +tool/.release.env +ios/vendor/bundle/ +ios/.bundle/ +ios/fastlane/report.xml diff --git a/ios/Gemfile b/ios/Gemfile index 7a118b4..d0072fd 100644 --- a/ios/Gemfile +++ b/ios/Gemfile @@ -1,3 +1,6 @@ +# fastlane drives the iOS release: archive, export, TestFlight upload. +# Pinned through Gemfile.lock so CI ships what a laptop tested. 2.240+ is +# required: older fastlane needs the abbrev gem, which Ruby 4 no longer bundles. source "https://rubygems.org" -gem "fastlane" +gem "fastlane", ">= 2.240" diff --git a/ios/Gemfile.lock b/ios/Gemfile.lock index 4b9cf7b..c732a1d 100644 --- a/ios/Gemfile.lock +++ b/ios/Gemfile.lock @@ -1,114 +1,116 @@ GEM remote: https://rubygems.org/ specs: - CFPropertyList (3.0.9) + CFPropertyList (3.0.8) abbrev (0.1.2) addressable (2.9.0) public_suffix (>= 2.0.2, < 8.0) artifactory (3.0.17) atomos (0.1.3) - aws-eventstream (1.3.2) - aws-partitions (1.1109.0) - aws-sdk-core (3.224.1) + aws-eventstream (1.4.0) + aws-partitions (1.1287.0) + aws-sdk-core (3.257.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) base64 + bigdecimal jmespath (~> 1, >= 1.6.1) logger - aws-sdk-kms (1.101.0) - aws-sdk-core (~> 3, >= 3.216.0) + rexml (~> 3.4, >= 3.4.2) + aws-sdk-kms (1.132.0) + aws-sdk-core (~> 3, >= 3.256.0) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.188.0) - aws-sdk-core (~> 3, >= 3.224.1) + aws-sdk-s3 (1.232.1) + aws-sdk-core (~> 3, >= 3.256.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) - aws-sigv4 (1.11.0) + aws-sigv4 (1.12.1) aws-eventstream (~> 1, >= 1.0.2) babosa (1.0.4) base64 (0.3.0) + benchmark (0.5.0) + bigdecimal (4.1.3) + cgi (0.5.2) claide (1.1.0) colored (1.2) colored2 (3.1.2) commander (4.6.0) highline (~> 2.0.0) - csv (3.3.5) + csv (3.3.6) declarative (0.0.20) digest-crc (0.7.0) rake (>= 12.0.0, < 14.0.0) - domain_name (0.5.20190701) - unf (>= 0.0.5, < 1.0.0) + domain_name (0.6.20260907) dotenv (2.8.1) emoji_regex (3.2.3) - excon (0.109.0) - faraday (1.10.5) - faraday-em_http (~> 1.0) - faraday-em_synchrony (~> 1.0) - faraday-excon (~> 1.1) - faraday-httpclient (~> 1.0) - faraday-multipart (~> 1.0) - faraday-net_http (~> 1.0) - faraday-net_http_persistent (~> 1.0) - faraday-patron (~> 1.0) - faraday-rack (~> 1.0) - faraday-retry (~> 1.0) - ruby2_keywords (>= 0.0.4) + erb (6.0.7) + excon (1.7.1) + logger + faraday (2.14.4) + faraday-net_http (>= 2.0, < 3.5) + json + logger faraday-cookie_jar (0.0.8) faraday (>= 0.8.0) http-cookie (>= 1.0.0) - faraday-em_http (1.0.0) - faraday-em_synchrony (1.0.1) - faraday-excon (1.1.0) - faraday-httpclient (1.0.1) + faraday-follow_redirects (0.5.0) + faraday (>= 1, < 3) faraday-multipart (1.2.0) multipart-post (~> 2.0) - faraday-net_http (1.0.2) - faraday-net_http_persistent (1.2.0) - faraday-patron (1.0.0) - faraday-rack (1.0.0) - faraday-retry (1.0.4) - faraday_middleware (1.2.1) - faraday (~> 1.0) + faraday-net_http (3.4.4) + net-http (~> 0.5) + faraday-retry (2.4.0) + faraday (~> 2.0) fastimage (2.4.1) - fastlane (2.229.0) - CFPropertyList (>= 2.3, < 4.0.0) - abbrev (~> 0.1.2) - addressable (>= 2.8, < 3.0.0) + fastlane (2.240.1) + CFPropertyList (>= 2.3, < 5.0.0) + abbrev (~> 0.1) + addressable (>= 2.9.0, < 3.0.0) artifactory (~> 3.0) - aws-sdk-s3 (~> 1.0) + aws-sdk-s3 (~> 1.197) babosa (>= 1.0.3, < 2.0.0) - bundler (>= 1.12.0, < 3.0.0) + base64 (~> 0.2) + benchmark (>= 0.1.0) + bundler (>= 2.4.0, < 5.0.0) + cgi (~> 0.4) colored (~> 1.2) commander (~> 4.6) csv (~> 3.3) dotenv (>= 2.1.1, < 3.0.0) emoji_regex (>= 0.1, < 4.0) - excon (>= 0.71.0, < 1.0.0) - faraday (~> 1.0) - faraday-cookie_jar (~> 0.0.6) - faraday_middleware (~> 1.0) + excon (>= 0.71.0, < 2.0.0) + faraday (~> 2.7) + faraday-cookie_jar (~> 0.0.8) + faraday-follow_redirects (~> 0.3) + faraday-multipart (~> 1.0) + faraday-retry (~> 2.0) fastimage (>= 2.1.0, < 3.0.0) - fastlane-sirp (>= 1.0.0) + fastlane-sirp (>= 1.1.0) gh_inspector (>= 1.1.2, < 2.0.0) - google-apis-androidpublisher_v3 (~> 0.3) + google-apis-androidpublisher_v3 (~> 0.99) google-apis-playcustomapp_v1 (~> 0.1) - google-cloud-env (>= 1.6.0, < 2.0.0) + google-cloud-env (>= 1.6.0, < 2.4.0) google-cloud-storage (~> 1.31) highline (~> 2.0) - http-cookie (~> 1.0.5) + irb (>= 1.8) json (< 3.0.0) - jwt (>= 2.1.0, < 3) + jwt (>= 2.10.3, < 4) + logger (>= 1.6, < 2.0) mini_magick (>= 4.9.4, < 5.0.0) + multi_json (~> 1.12) multipart-post (>= 2.0.0, < 3.0.0) - mutex_m (~> 0.3.0) + mutex_m (~> 0.3) naturally (~> 2.2) + nkf (~> 0.2) optparse (>= 0.1.1, < 1.0.0) + ostruct (>= 0.1.0) plist (>= 3.1.0, < 4.0.0) - rubyzip (>= 2.0.0, < 3.0.0) - security (= 0.1.5) + rubyzip (>= 3.4.0, < 4.0.0) + security (~> 0.3) simctl (~> 1.6.3) terminal-notifier (>= 2.0.0, < 3.0.0) - terminal-table (~> 3) + terminal-table (~> 4) tty-screen (>= 0.6.3, < 1.0.0) tty-spinner (>= 0.8.0, < 1.0.0) word_wrap (~> 1.0.0) @@ -117,100 +119,133 @@ GEM xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) fastlane-sirp (1.1.0) gh_inspector (1.1.3) - google-apis-androidpublisher_v3 (0.54.0) - google-apis-core (>= 0.11.0, < 2.a) - google-apis-core (0.11.3) - addressable (~> 2.5, >= 2.5.1) - googleauth (>= 0.16.2, < 2.a) - httpclient (>= 2.8.1, < 3.a) - mini_mime (~> 1.0) + google-apis-androidpublisher_v3 (0.108.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-core (1.2.5) + addressable (~> 2.9) + faraday (~> 2.13) + faraday-follow_redirects (~> 0.3) + googleauth (~> 1.14) + mini_mime (~> 1.1) + multi_json (~> 1.11) representable (~> 3.0) - retriable (>= 2.0, < 4.a) - rexml - google-apis-iamcredentials_v1 (0.17.0) - google-apis-core (>= 0.11.0, < 2.a) - google-apis-playcustomapp_v1 (0.13.0) - google-apis-core (>= 0.11.0, < 2.a) - google-apis-storage_v1 (0.32.0) - google-apis-core (>= 0.11.0, < 2.a) - google-cloud-core (1.6.1) + retriable (>= 3.1, < 5.0) + google-apis-iamcredentials_v1 (0.28.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-playcustomapp_v1 (0.18.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-storage_v1 (0.67.0) + google-apis-core (>= 0.15.0, < 2.a) + google-cloud-core (1.9.0) google-cloud-env (>= 1.0, < 3.a) google-cloud-errors (~> 1.0) - google-cloud-env (1.6.0) - faraday (>= 0.17.3, < 3.0) - google-cloud-errors (1.3.1) - google-cloud-storage (1.37.0) + google-cloud-env (2.3.1) + base64 (~> 0.2) + faraday (>= 1.0, < 3.a) + google-cloud-errors (1.7.0) + google-cloud-storage (1.62.0) addressable (~> 2.8) digest-crc (~> 0.4) - google-apis-iamcredentials_v1 (~> 0.1) - google-apis-storage_v1 (~> 0.1) + google-apis-core (>= 0.18, < 2) + google-apis-iamcredentials_v1 (~> 0.18) + google-apis-storage_v1 (>= 0.42) google-cloud-core (~> 1.6) - googleauth (>= 0.16.2, < 2.a) + googleauth (~> 1.9) mini_mime (~> 1.0) - googleauth (1.8.1) - faraday (>= 0.17.3, < 3.a) - jwt (>= 1.4, < 3.0) - multi_json (~> 1.11) + google-logging-utils (0.2.0) + googleauth (1.17.4) + faraday (>= 1.0, < 3.a) + google-cloud-env (~> 2.2) + google-logging-utils (~> 0.1) + jwt (>= 1.4, < 4.0) os (>= 0.9, < 2.0) + pstore (~> 0.1) signet (>= 0.16, < 2.a) highline (2.0.3) - http-cookie (1.0.8) + http-cookie (1.1.6) domain_name (~> 0.5) - httpclient (2.9.0) - mutex_m + io-console (0.9.4) + irb (1.18.0) + pp (>= 0.6.0) + prism (>= 1.3.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) jmespath (1.6.2) - json (2.7.6) - jwt (2.10.3) + json (2.21.2) + jwt (3.3.0) base64 logger (1.7.0) mini_magick (4.13.2) mini_mime (1.1.5) - multi_json (1.15.0) + multi_json (1.21.2) multipart-post (2.4.1) mutex_m (0.3.0) nanaimo (0.4.0) naturally (2.3.0) + net-http (0.9.1) + uri (>= 0.11.1) + nkf (0.3.0) optparse (0.8.1) os (1.1.4) + ostruct (0.6.3) plist (3.7.2) - public_suffix (5.1.1) + pp (0.6.4) + prettyprint + prettyprint (0.2.0) + prism (1.9.0) + pstore (0.2.1) + public_suffix (7.0.5) rake (13.4.2) + rbs (4.2.0) + logger + prism (>= 1.6.0) + tsort + rdoc (8.0.0) + erb + prism (>= 1.6.0) + rbs (>= 4.0.0) + tsort + reline (0.7.0) + io-console (~> 0.5) representable (3.2.0) declarative (< 0.1.0) trailblazer-option (>= 0.1.1, < 0.2.0) uber (< 0.2.0) - retriable (3.8.0) + retriable (4.2.0) rexml (3.4.4) rouge (3.28.0) - ruby2_keywords (0.0.5) - rubyzip (2.4.1) - security (0.1.5) - signet (0.18.0) + rubyzip (3.6.0) + security (0.3.0) + signet (0.22.0) addressable (~> 2.8) faraday (>= 0.17.5, < 3.a) - jwt (>= 1.5, < 3.0) - multi_json (~> 1.10) + jwt (>= 1.5, < 4.0) simctl (1.6.10) CFPropertyList naturally terminal-notifier (2.0.0) - terminal-table (3.0.2) - unicode-display_width (>= 1.1.1, < 3) + terminal-table (4.0.0) + unicode-display_width (>= 1.1.1, < 4) trailblazer-option (0.1.2) + tsort (0.2.0) tty-cursor (0.7.1) tty-screen (0.8.2) tty-spinner (0.9.3) tty-cursor (~> 0.7) uber (0.1.0) - unf (0.2.0) - unicode-display_width (2.6.0) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.2.0) + uri (1.1.1) word_wrap (1.0.0) - xcodeproj (1.27.0) + xcodeproj (1.28.1) CFPropertyList (>= 2.3.3, < 4.0) atomos (~> 0.1.3) + base64 claide (>= 1.0.2, < 2.0) colored2 (~> 3.1) nanaimo (~> 0.4.0) + nkf rexml (>= 3.3.6, < 4.0) xcpretty (0.4.1) rouge (~> 3.28.0) @@ -222,7 +257,7 @@ PLATFORMS ruby DEPENDENCIES - fastlane + fastlane (>= 2.240) BUNDLED WITH 2.4.22 diff --git a/ios/fastlane/Fastfile b/ios/fastlane/Fastfile index 7cb1c0a..23c934b 100644 --- a/ios/fastlane/Fastfile +++ b/ios/fastlane/Fastfile @@ -13,6 +13,9 @@ # Uncomment the line if you want fastlane to automatically update itself # update_fastlane +require "shellwords" +require "tmpdir" + default_platform(:ios) platform :ios do @@ -20,6 +23,66 @@ platform :ios do File.expand_path("../..", __dir__) end + # tool/release.sh exports an App Store Connect key; with it these lanes run + # with nobody at the keyboard, and without it they fall back to the + # interactive Apple ID login they have always used. + def asc_key + return nil unless ENV["ASC_KEY_ID"] && ENV["ASC_KEY_P8"] + + app_store_connect_api_key( + key_id: ENV.fetch("ASC_KEY_ID"), + issuer_id: ENV.fetch("ASC_ISSUER_ID"), + key_content: ENV.fetch("ASC_KEY_P8"), + is_key_content_base64: true, + in_house: false, + ) + end + + # pubspec's +build is a floor, not the last word: TestFlight knows what it + # has already accepted, and a duplicate number is refused outright. So a + # release no longer depends on remembering `make bump` first — bump when the + # version should change, and the build number takes care of itself. + def next_build_number(key) + return ENV.fetch("RELEASE_BUILD", "1").to_i if key.nil? + + latest = latest_testflight_build_number( + api_key: key, + app_identifier: "com.meshcore.sar.meshcoreSarApp", + version: ENV["RELEASE_VERSION"], + initial_build_number: 0, + ) + [ENV.fetch("RELEASE_BUILD", "0").to_i, latest + 1].max + end + + # gym has no api_key option — it shells out to xcodebuild, so the key goes in + # as authentication flags pointing at a file. Only the archive needs them: + # gym adds the same flags to the export itself, and xcodebuild rejects a + # repeated -authenticationKeyPath. + def with_archive_args(build_number = nil) + return yield(nil) unless ENV["ASC_KEY_ID"] && ENV["ASC_KEY_P8"] + + path = File.join(Dir.tmpdir, "AuthKey_#{ENV.fetch('ASC_KEY_ID')}.p8") + File.binwrite(path, ENV.fetch("ASC_KEY_P8").unpack1("m")) + File.chmod(0o600, path) + args = [ + "-allowProvisioningUpdates", + "-authenticationKeyPath", Shellwords.escape(path), + "-authenticationKeyID", Shellwords.escape(ENV.fetch("ASC_KEY_ID")), + "-authenticationKeyIssuerID", Shellwords.escape(ENV.fetch("ASC_ISSUER_ID")), + ] + # A build setting on the command line outranks any xcconfig, so the number + # the caller decided survives the archive regenerating Generated.xcconfig. + if ENV["RELEASE_VERSION"] + args << "FLUTTER_BUILD_NAME=#{ENV.fetch('RELEASE_VERSION')}" + args << "FLUTTER_BUILD_NUMBER=#{build_number}" if build_number + end + begin + yield(args.join(" ")) + ensure + File.delete(path) if File.exist?(path) + end + end + def beta_app_info { "en-US" => { @@ -41,9 +104,12 @@ platform :ios do desc "Push a new release build to the App Store" lane :release do - increment_build_number(xcodeproj: "Runner.xcodeproj") - build_app(workspace: "Runner.xcworkspace", scheme: "Runner") + key = asc_key + build = next_build_number(key) + UI.message("building #{ENV['RELEASE_VERSION'] || 'pubspec version'} (#{build})") + with_archive_args(build) { |args| build_app(workspace: "Runner.xcworkspace", scheme: "Runner", xcargs: args) } upload_to_testflight( + api_key: key, skip_waiting_for_build_processing: true, localized_app_info: beta_app_info, localized_build_info: beta_build_info, @@ -53,9 +119,12 @@ platform :ios do desc "Push a new beta build to TestFlight" lane :beta do - increment_build_number(xcodeproj: "Runner.xcodeproj") - build_app(workspace: "Runner.xcworkspace", scheme: "Runner") + key = asc_key + build = next_build_number(key) + UI.message("building #{ENV['RELEASE_VERSION'] || 'pubspec version'} (#{build})") + with_archive_args(build) { |args| build_app(workspace: "Runner.xcworkspace", scheme: "Runner", xcargs: args) } upload_to_testflight( + api_key: key, skip_waiting_for_build_processing: true, localized_app_info: beta_app_info, localized_build_info: beta_build_info, @@ -71,6 +140,7 @@ platform :ios do desc "Upload App Store screenshots" lane :store_assets do upload_to_app_store( + api_key: asc_key, skip_binary_upload: true, skip_metadata: true, skip_screenshots: false, diff --git a/tool/release.sh b/tool/release.sh new file mode 100755 index 0000000..a8d89b3 --- /dev/null +++ b/tool/release.sh @@ -0,0 +1,277 @@ +#!/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 <&2; exit 1; } + +# ---------------------------------------------------------------- arguments -- + +COMMAND="push" +DRY_RUN=0 +REPO="" +MACOS_IDENTITY="" +IOS_IDENTITY="" +ASC_KEY_FILE="${ASC_KEY_FILE:-}" +ASC_KEY_ID="${ASC_KEY_ID:-}" +ASC_ISSUER_ID="${ASC_ISSUER_ID:-}" +ENV_FILE="$APP_DIR/tool/.release.env" + +usage() { + sed -n '2,25p' "${BASH_SOURCE[0]}" | sed 's/^#\{1\} \{0,1\}//' + exit "${1:-0}" +} + +if [ $# -gt 0 ]; then + case "$1" in + push|list|env) COMMAND="$1"; shift ;; + esac +fi + +while [ $# -gt 0 ]; do + case "$1" in + --dry-run) DRY_RUN=1; shift ;; + --repo) REPO="${2:-}"; shift 2 ;; + --macos-identity) MACOS_IDENTITY="${2:-}"; shift 2 ;; + --ios-identity) IOS_IDENTITY="${2:-}"; shift 2 ;; + --asc-key) ASC_KEY_FILE="${2:-}"; shift 2 ;; + --asc-key-id) ASC_KEY_ID="${2:-}"; shift 2 ;; + --asc-issuer) ASC_ISSUER_ID="${2:-}"; shift 2 ;; + -h|--help) usage ;; + *) die "unknown option '$1'" ;; + esac +done + +[ "$COMMAND" = "env" ] || command -v gh >/dev/null 2>&1 || die "gh not found (brew install gh)" +command -v openssl >/dev/null 2>&1 || die "openssl not found" + +if [ -z "$REPO" ]; then + REPO="$(git -C "$ROOT_DIR" remote get-url origin 2>/dev/null \ + | sed -E 's#^git@github\.com:##; s#^https://github\.com/##; s#\.git$##')" +fi +[ -n "$REPO" ] || die "no repo: pass --repo owner/name" + +WORK="$(mktemp -d -t meshcore-secrets)" +trap 'rm -rf "$WORK"' EXIT + +# ---------------------------------------------------------------- identities -- + +# `security find-identity` prints one line per usable identity; we want the +# named kind issued to this team, and we want to fail loudly on ambiguity rather +# than sign a release with whichever one sorted first. +find_identity() { + local kind="$1" rows hashes + # Each row is " ". Xcode installs a copy of a certificate every + # time it fetches one, so the same identity turns up several times; that is + # not ambiguity. Only distinct certificates are. + rows="$(security find-identity -v -p codesigning \ + | sed -n 's/^ *[0-9]*) \([0-9A-F]*\) "\(.*\)"$/\1 \2/p' \ + | grep " $kind:" | grep "($TEAM)" || true)" + [ -n "$rows" ] || die "no \"$kind\" identity for team $TEAM in the keychain" + + hashes="$(printf '%s\n' "$rows" | cut -d' ' -f1 | sort -u)" + if [ "$(printf '%s\n' "$hashes" | wc -l)" -gt 1 ]; then + printf 'error: several different "%s" certificates for team %s:\n' "$kind" "$TEAM" >&2 + printf '%s\n' "$rows" | sort -u | sed 's/^/ /' >&2 + die "pick one with --macos-identity / --ios-identity" + fi + printf '%s' "$(printf '%s\n' "$rows" | head -1 | cut -d' ' -f2-)" +} + +[ -n "$MACOS_IDENTITY" ] || MACOS_IDENTITY="$(find_identity 'Developer ID Application')" +[ -n "$IOS_IDENTITY" ] || IOS_IDENTITY="$(find_identity 'Apple Distribution')" + +if [ "$COMMAND" = "list" ]; then + say "repo $REPO" + gh secret list --repo "$REPO" || true + printf '\n' + say "local identities (team $TEAM)" + printf ' macOS %s\n iOS %s\n' "$MACOS_IDENTITY" "$IOS_IDENTITY" + exit 0 +fi + +# ------------------------------------------------------------------- export -- + +# `security export` has no way to name a single identity, so everything comes out +# in one bundle and openssl splits it back apart. A cert and its key share a +# localKeyID inside the bundle, which is what pairs them here. +BUNDLE="$WORK/all.p12" +BUNDLE_PW="$(uuidgen)" +BAGS="$WORK/bags.pem" + +say "exporting identities from the login keychain (allow the prompt)" +security export -k "$HOME/Library/Keychains/login.keychain-db" \ + -t identities -f pkcs12 -P "$BUNDLE_PW" -o "$BUNDLE" \ + || die "export refused — the prompt needs Allow, not Deny" + +openssl pkcs12 -in "$BUNDLE" -passin "pass:$BUNDLE_PW" -nodes -legacy -out "$BAGS" 2>/dev/null \ + || openssl pkcs12 -in "$BUNDLE" -passin "pass:$BUNDLE_PW" -nodes -out "$BAGS" \ + || die "openssl could not read the exported bundle" +rm -f "$BUNDLE" + +# Apple's intermediates ride along in the .p12 so the runner can build a chain +# to the root without having to already trust the right CA. +CHAIN="$WORK/chain.pem" +: > "$CHAIN" +for ca in "Apple Worldwide Developer Relations" "Developer ID Certification Authority"; do + security find-certificate -a -c "$ca" -p >> "$CHAIN" 2>/dev/null || true +done + +# Pulls one identity out of the bundle: the cert bag with this friendlyName, and +# the key bag carrying the same localKeyID. +split_identity() { + local name="$1" out_cert="$2" out_key="$3" + BAGS="$BAGS" NAME="$name" CERT="$out_cert" KEY="$out_key" python3 - <<'PY' +import os, re, sys + +bags = open(os.environ["BAGS"]).read() +blocks = re.findall(r"Bag Attributes.*?-----END [A-Z ]+-----\n", bags, re.S) + +def attr(block, key): + m = re.search(rf"^\s*{key}:\s*(.+)$", block, re.M) + return m.group(1).strip() if m else None + +want = os.environ["NAME"] +cert = next((b for b in blocks + if "BEGIN CERTIFICATE" in b and attr(b, "friendlyName") == want), None) +if cert is None: + sys.exit(f"no certificate named {want!r} in the exported bundle") + +key_id = attr(cert, "localKeyID") +key = next((b for b in blocks + if "PRIVATE KEY" in b and attr(b, "localKeyID") == key_id), None) +if key is None: + sys.exit(f"{want!r} has no private key in the keychain — it cannot sign") + +pem = lambda b: b[b.index("-----BEGIN"):] +open(os.environ["CERT"], "w").write(pem(cert)) +open(os.environ["KEY"], "w").write(pem(key)) +PY +} + +# Repacks one identity into its own .p12 and prints " ". +pack_identity() { + local name="$1" + local cert="$WORK/leaf.pem" key="$WORK/leaf.key" p12="$WORK/leaf.p12" + local pw; pw="$(uuidgen)" + split_identity "$name" "$cert" "$key" + # An empty -certfile is an error rather than a no-op, so only pass it when + # the intermediates were actually found. -legacy keeps the encryption to what + # macOS `security import` reads without argument on every runner image. + local chain=() + if [ -s "$CHAIN" ]; then chain=(-certfile "$CHAIN"); fi + openssl pkcs12 -export -legacy -out "$p12" -inkey "$key" -in "$cert" \ + ${chain[@]+"${chain[@]}"} -name "$name" -passout "pass:$pw" 2>/dev/null \ + || openssl pkcs12 -export -out "$p12" -inkey "$key" -in "$cert" \ + ${chain[@]+"${chain[@]}"} -name "$name" -passout "pass:$pw" + printf '%s %s' "$(base64 < "$p12" | tr -d '\n')" "$pw" + rm -f "$cert" "$key" "$p12" +} + +say "packing ${MACOS_IDENTITY}" +read -r MACOS_CERT_P12 MACOS_CERT_PASSWORD <<<"$(pack_identity "$MACOS_IDENTITY")" +say "packing ${IOS_IDENTITY}" +read -r IOS_CERT_P12 IOS_CERT_PASSWORD <<<"$(pack_identity "$IOS_IDENTITY")" + +# ---------------------------------------------------------- app store connect -- + +ASC_KEY_P8="" +if [ -n "$ASC_KEY_FILE" ]; then + [ -f "$ASC_KEY_FILE" ] || die "no such key file: $ASC_KEY_FILE" + ASC_KEY_P8="$(base64 < "$ASC_KEY_FILE" | tr -d '\n')" + # AuthKey_ABC123.p8 names the key it holds; take the id from the filename + # unless one was given, because that is one fewer thing to mistype. + if [ -z "$ASC_KEY_ID" ]; then + base="$(basename "$ASC_KEY_FILE")"; base="${base%.p8}" + ASC_KEY_ID="${base#AuthKey_}" + fi + [ -n "$ASC_ISSUER_ID" ] || die "--asc-key needs --asc-issuer too" +fi + +# --------------------------------------------------------------------- push -- + +set_secret() { + local name="$1" value="$2" + [ -n "$value" ] || return 0 + if [ "$DRY_RUN" -eq 1 ]; then + printf ' %-20s %s bytes\n' "$name" "${#value}" + return 0 + fi + if [ "$COMMAND" = "env" ]; then + # Single-quoted so base64 padding and the spaces in an identity name stay + # literal; the only character that could break out is escaped. + printf "%s='%s'\n" "$name" "${value//\'/\'\\\'\'}" >> "$ENV_FILE" + printf ' %-20s written\n' "$name" + return 0 + fi + # Through stdin, never as an argument: arguments are readable in `ps`. + printf '%s' "$value" | gh secret set "$name" --repo "$REPO" + printf ' %-20s set\n' "$name" +} + +if [ "$DRY_RUN" -eq 1 ]; then + say "dry run — $REPO would receive:" +elif [ "$COMMAND" = "env" ]; then + say "writing ${ENV_FILE#"$ROOT_DIR"/}" + # Created empty and locked down before anything is appended: the private keys + # must never exist in a world-readable file, not even for an instant. + rm -f "$ENV_FILE" + install -m 600 /dev/null "$ENV_FILE" + printf '# Written by tool/secrets.sh — read by tool/release.sh. Not in git.\n' >> "$ENV_FILE" +else + say "pushing to $REPO" +fi + +set_secret MACOS_CERT_P12 "$MACOS_CERT_P12" +set_secret MACOS_CERT_PASSWORD "$MACOS_CERT_PASSWORD" +set_secret MACOS_SIGN_ID "$MACOS_IDENTITY" +set_secret IOS_CERT_P12 "$IOS_CERT_P12" +set_secret IOS_CERT_PASSWORD "$IOS_CERT_PASSWORD" +set_secret ASC_KEY_ID "$ASC_KEY_ID" +set_secret ASC_ISSUER_ID "$ASC_ISSUER_ID" +set_secret ASC_KEY_P8 "$ASC_KEY_P8" + +if [ -z "$ASC_KEY_P8" ]; then + printf '\nnote: no App Store Connect key given, so ASC_KEY_ID, ASC_ISSUER_ID and\n' + printf ' ASC_KEY_P8 were left alone. Add them with:\n' + printf ' ./tool/secrets.sh --asc-key AuthKey_XXX.p8 --asc-issuer \n' +fi