mirror of
https://github.com/Such-Software/hash-wallet.git
synced 2026-11-07 08:04:04 +01:00
The macOS lane has never gone green. It built with the project's Release signing, which was still Cake's (automatic Apple Development, team 32J6BB6VUS), and failed at "No profiles for 'com.suchsoftware...'". Past that it would have re-signed the finished app by hand, shipping keychain-access-groups as a literal "$(AppIdentifierPrefix)" with no profile to authorise it, and read notary secrets that do not exist. Now, on the shape Vegan IQ proved for its store build: - Runner's Release config signs manually under D8PL9F7X33 and takes its profile name from HASH_BAGS_MACOS_PROFILE, so only the app carries one. - scripts/macos/build_signed.sh decodes a profile, picks the keychain identity it was issued for (check_macos_profile.py, from vegan-IQ), and signs inside xcodebuild; it refuses an app whose entitlements keep an unexpanded variable or that embeds no profile. - developer-id lane: hardened runtime, notarize, staple, spctl assess, zip to the release. app-store lane: productbuild with the installer identity, pkgutil check, altool upload to App Store Connect. - The first step refuses a selected lane missing any of its secrets and names each one. The API key lives under the job's temp dir, not the runner home. Not run: no Developer ID or Mac App Store signing material is in the repository's secrets yet, so the guard is the only part exercised.
530 lines
26 KiB
YAML
530 lines
26 KiB
YAML
# Hash Bags macOS builds, two lanes from one configured tree.
|
|
#
|
|
# developer-id Direct download: a zip of the .app signed with Developer ID
|
|
# Application, notarized and stapled, published to the release.
|
|
# app-store Mac App Store: a .pkg whose .app is signed with Apple
|
|
# Distribution and whose installer is signed with 3rd Party Mac
|
|
# Developer Installer, uploaded to App Store Connect.
|
|
#
|
|
# Each lane signs inside xcodebuild against its own provisioning profile
|
|
# (scripts/macos/build_signed.sh). The approach and every failure it avoids are
|
|
# in docs infra/macos-app-store-release.md, from Vegan IQ's first store build.
|
|
#
|
|
# Secrets. A selected lane with any of its own missing refuses in the first
|
|
# step, naming each one, before forty minutes of prerequisites.
|
|
# both lanes APPLE_KEYCHAIN_PASSWORD, APPLE_KEY_ID, APPLE_ISSUER_ID,
|
|
# APPLE_API_KEY_P8_BASE64 (notarytool and altool)
|
|
# developer-id MACOS_DEVELOPER_ID_CERT_P12_BASE64, MACOS_DEVELOPER_ID_CERT_P12_PASSWORD,
|
|
# MACOS_DEVELOPER_ID_PROFILE_BASE64 (a "Developer ID" profile)
|
|
# app-store APPLE_DIST_CERT_P12_BASE64, APPLE_DIST_CERT_P12_PASSWORD (shared
|
|
# with iOS), MACOS_INSTALLER_CERT_P12_BASE64,
|
|
# MACOS_INSTALLER_CERT_P12_PASSWORD, MACOS_APP_STORE_PROFILE_BASE64
|
|
# optional GH_RELEASE_TOKEN publishes the Developer ID zip to the release
|
|
|
|
name: Hash Bags macOS build
|
|
|
|
on:
|
|
push:
|
|
tags:
|
|
- 'hash-bags-*'
|
|
workflow_dispatch:
|
|
inputs:
|
|
lanes:
|
|
description: Which macOS builds to produce
|
|
type: choice
|
|
options: [both, developer-id, app-store]
|
|
default: both
|
|
app_store_upload:
|
|
description: >-
|
|
Upload the App Store package to App Store Connect. A build number
|
|
already uploaded is refused by Apple, so a rebuild of the same
|
|
version sets this false.
|
|
type: boolean
|
|
default: true
|
|
release_tag:
|
|
description: >-
|
|
Existing release tag to publish to (e.g. hash-bags-1.0.3). Leave empty
|
|
to build without publishing. The release must already exist; this
|
|
never creates one, because a release created here would go public
|
|
half-populated while the other platforms are still building.
|
|
required: false
|
|
default: ''
|
|
|
|
concurrency:
|
|
group: macos-${{ github.ref }}
|
|
cancel-in-progress: true
|
|
|
|
defaults:
|
|
run:
|
|
shell: bash
|
|
|
|
jobs:
|
|
build:
|
|
name: Build, sign, notarize
|
|
runs-on: macos-latest
|
|
timeout-minutes: 90
|
|
env:
|
|
APP_MACOS_TYPE: cakewallet
|
|
# A tag push builds both lanes and uploads; a dispatch says what it wants.
|
|
LANES: ${{ github.event.inputs.lanes || 'both' }}
|
|
APP_STORE_UPLOAD: ${{ github.event.inputs.app_store_upload || 'true' }}
|
|
KEYCHAIN: ${{ github.workspace }}/../hashbags-macos.keychain-db
|
|
LANG: en_US.UTF-8
|
|
LC_ALL: en_US.UTF-8
|
|
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
with:
|
|
fetch-depth: 1
|
|
|
|
# Cheap guards first: a selected lane without its signing material refuses
|
|
# here, in seconds, naming every missing secret. Presence only, never values.
|
|
- name: Require each selected lane's signing material
|
|
env:
|
|
APPLE_KEYCHAIN_PASSWORD: ${{ secrets.APPLE_KEYCHAIN_PASSWORD }}
|
|
APPLE_KEY_ID: ${{ secrets.APPLE_KEY_ID }}
|
|
APPLE_ISSUER_ID: ${{ secrets.APPLE_ISSUER_ID }}
|
|
APPLE_API_KEY_P8_BASE64: ${{ secrets.APPLE_API_KEY_P8_BASE64 }}
|
|
MACOS_DEVELOPER_ID_CERT_P12_BASE64: ${{ secrets.MACOS_DEVELOPER_ID_CERT_P12_BASE64 }}
|
|
MACOS_DEVELOPER_ID_CERT_P12_PASSWORD: ${{ secrets.MACOS_DEVELOPER_ID_CERT_P12_PASSWORD }}
|
|
MACOS_DEVELOPER_ID_PROFILE_BASE64: ${{ secrets.MACOS_DEVELOPER_ID_PROFILE_BASE64 }}
|
|
APPLE_DIST_CERT_P12_BASE64: ${{ secrets.APPLE_DIST_CERT_P12_BASE64 }}
|
|
APPLE_DIST_CERT_P12_PASSWORD: ${{ secrets.APPLE_DIST_CERT_P12_PASSWORD }}
|
|
MACOS_INSTALLER_CERT_P12_BASE64: ${{ secrets.MACOS_INSTALLER_CERT_P12_BASE64 }}
|
|
MACOS_INSTALLER_CERT_P12_PASSWORD: ${{ secrets.MACOS_INSTALLER_CERT_P12_PASSWORD }}
|
|
MACOS_APP_STORE_PROFILE_BASE64: ${{ secrets.MACOS_APP_STORE_PROFILE_BASE64 }}
|
|
run: |
|
|
set -euo pipefail
|
|
case "$LANES" in both|developer-id|app-store) ;; *)
|
|
echo "FATAL: lanes must be both, developer-id or app-store, not '$LANES'"; exit 64 ;; esac
|
|
need="APPLE_KEYCHAIN_PASSWORD APPLE_KEY_ID APPLE_ISSUER_ID APPLE_API_KEY_P8_BASE64"
|
|
if [ "$LANES" != app-store ]; then
|
|
need="$need MACOS_DEVELOPER_ID_CERT_P12_BASE64 MACOS_DEVELOPER_ID_CERT_P12_PASSWORD MACOS_DEVELOPER_ID_PROFILE_BASE64"
|
|
fi
|
|
if [ "$LANES" != developer-id ]; then
|
|
need="$need APPLE_DIST_CERT_P12_BASE64 APPLE_DIST_CERT_P12_PASSWORD MACOS_INSTALLER_CERT_P12_BASE64 MACOS_INSTALLER_CERT_P12_PASSWORD MACOS_APP_STORE_PROFILE_BASE64"
|
|
fi
|
|
missing=""
|
|
for v in $need; do
|
|
eval "val=\${$v:-}"
|
|
[ -n "$val" ] || missing="$missing $v"
|
|
done
|
|
if [ -n "$missing" ]; then
|
|
echo "FATAL: lanes=$LANES needs these repository secrets, which are not set:"
|
|
for v in $missing; do echo " $v"; done
|
|
echo "The certificates and profiles they hold, and how to export them, are in"
|
|
echo "docs infra/apple-desktop-signing.md. Run one lane with the lanes input"
|
|
echo "while the other's material is still being gathered."
|
|
exit 1
|
|
fi
|
|
echo "lanes=$LANES: signing material present; App Store upload=$APP_STORE_UPLOAD"
|
|
|
|
- name: Check Flutter 3.41.9
|
|
id: flutter_check
|
|
run: |
|
|
if command -v flutter >/dev/null && flutter --version 2>/dev/null | grep -q "3\.41\.9"; then
|
|
echo "installed=true" >> "$GITHUB_OUTPUT"
|
|
flutter --version
|
|
else
|
|
echo "installed=false" >> "$GITHUB_OUTPUT"
|
|
fi
|
|
|
|
- name: Install Flutter 3.41.9 (if missing)
|
|
if: steps.flutter_check.outputs.installed != 'true'
|
|
uses: subosito/flutter-action@v2
|
|
with:
|
|
flutter-version: '3.41.9'
|
|
channel: stable
|
|
cache: true
|
|
|
|
- name: Install CocoaPods (if missing)
|
|
run: |
|
|
set -euo pipefail
|
|
if command -v pod >/dev/null; then pod --version; exit 0; fi
|
|
if command -v brew >/dev/null; then
|
|
brew install cocoapods
|
|
else
|
|
export GEM_HOME="$HOME/.gem"
|
|
echo "GEM_HOME=$HOME/.gem" >> "$GITHUB_ENV"
|
|
echo "$HOME/.gem/bin" >> "$GITHUB_PATH"
|
|
gem install --user-install cocoapods
|
|
fi
|
|
|
|
# ---- Prerequisites ------------------------------------------------------
|
|
# Ported step for step from build-linux.yml, which is the desktop build
|
|
# known to pass, rather than written fresh. The first version of this
|
|
# workflow was written fresh: it skipped the clone that creates
|
|
# scripts/monero_c and died there, and behind that it had also skipped the
|
|
# step that pins the crypto salts. curl instead of wget, and BSD sed's
|
|
# -i.bak form, because this runner is a Mac.
|
|
- name: Fetch prebuilt torch_dart
|
|
run: |
|
|
set -euo pipefail
|
|
pushd scripts
|
|
rm -rf torch_dart torch_dart.tar.gz
|
|
curl -fsSL https://github.com/MrCyjaneK/torch_dart/releases/download/v1.0.17/torch_dart-v1.0.17.tar.gz -o torch_dart.tar.gz
|
|
mkdir torch_dart
|
|
tar -xzf torch_dart.tar.gz -C torch_dart
|
|
rm torch_dart.tar.gz
|
|
popd
|
|
|
|
- name: Fetch prebuilt reown_flutter
|
|
run: |
|
|
set -euo pipefail
|
|
pushd scripts
|
|
rm -rf reown_flutter reown_flutter.tar.gz
|
|
curl -fsSL https://github.com/cake-tech/reown_flutter/releases/download/v0.0.4/reown_flutter-v0.0.4.tar.gz -o reown_flutter.tar.gz
|
|
mkdir reown_flutter
|
|
tar -xzf reown_flutter.tar.gz -C reown_flutter
|
|
rm reown_flutter.tar.gz
|
|
popd
|
|
|
|
# The iOS variant, not build_bitbox_flutter.sh: that script also builds
|
|
# the Android .aar through gomobile, which an Apple target does not use.
|
|
- name: Clone BitBox Flutter (Apple — skip Android bindings)
|
|
run: |
|
|
set -euo pipefail
|
|
pushd scripts
|
|
if [[ ! -d bitbox_flutter ]]; then
|
|
git clone https://github.com/konstantinullrich/bitbox_flutter
|
|
fi
|
|
cd bitbox_flutter
|
|
git fetch -a
|
|
git reset --hard
|
|
git checkout 5a6e6dd388ef64003f86094af80d5453518b601d
|
|
git reset --hard
|
|
popd
|
|
|
|
# ---- Native crypto cores ----------------------------------------------
|
|
# prepare_moneroc.sh clones scripts/monero_c at the pinned revision. It
|
|
# is not a submodule, so checkout alone leaves the directory absent.
|
|
- name: Fetch prebuilt monero_c bundle
|
|
run: |
|
|
set -euo pipefail
|
|
./scripts/prepare_moneroc.sh
|
|
MONERO_C_TAG=$(cd scripts/monero_c && git describe --tags)
|
|
echo "monero_c TAG: $MONERO_C_TAG"
|
|
mkdir -p "scripts/monero_c/release/$MONERO_C_TAG"
|
|
pushd "scripts/monero_c/release/$MONERO_C_TAG"
|
|
if [ ! -d aarch64-apple-darwin ]; then
|
|
curl -fsSL -O https://github.com/MrCyjaneK/monero_c/releases/download/v0.18.4.6-RC2/release-bundle.zip
|
|
unzip -q release-bundle.zip
|
|
# RC2 nests everything under an inner v0.18.4.6-*/ dir; strip it.
|
|
shopt -s nullglob
|
|
for inner in v0.18.4.6-*/; do mv "$inner"* . && rmdir "$inner"; done
|
|
shopt -u nullglob
|
|
rm release-bundle.zip
|
|
fi
|
|
ls -la aarch64-apple-darwin x86_64-apple-darwin
|
|
popd
|
|
|
|
# Runner.xcodeproj already references these two by bare filename with
|
|
# CodeSignOnCopy, so they belong beside the project at macos/.
|
|
- name: Stage universal dylibs into macos/
|
|
run: |
|
|
set -euo pipefail
|
|
MONERO_C_TAG=$(cd scripts/monero_c && git describe --tags)
|
|
BUNDLE="scripts/monero_c/release/$MONERO_C_TAG"
|
|
for lib in libmonero_wallet2_api_c libwownero_wallet2_api_c; do
|
|
arm="$BUNDLE/aarch64-apple-darwin/$lib.dylib"
|
|
x86="$BUNDLE/x86_64-apple-darwin/$lib.dylib"
|
|
[ -f "$arm" ] || { echo "missing $arm"; exit 1; }
|
|
# Universal, so one download serves Apple Silicon and Intel. A
|
|
# single-arch build looks fine on the machine that made it and
|
|
# fails on half the users' machines.
|
|
if [ -f "$x86" ]; then
|
|
lipo -create "$arm" "$x86" -output "macos/$lib.dylib"
|
|
else
|
|
echo "no x86_64 slice for $lib; shipping arm64 only"
|
|
cp "$arm" "macos/$lib.dylib"
|
|
fi
|
|
lipo -info "macos/$lib.dylib"
|
|
done
|
|
|
|
# ---- Configure --------------------------------------------------------
|
|
# app_env.sh sets the name, bundle id and version that app_config.sh
|
|
# writes into Info.plist; without it PlistBuddy writes empty strings. It
|
|
# has to be sourced in the same shell, and app_config.sh has to start in
|
|
# scripts/macos because it walks up with relative cds.
|
|
- name: Run configure (cakewallet build profile)
|
|
run: |
|
|
set -euo pipefail
|
|
pushd scripts/macos
|
|
source ./app_env.sh cakewallet
|
|
./app_config.sh
|
|
popd
|
|
/usr/libexec/PlistBuddy -c "Print :CFBundleName" macos/Runner/Info.plist
|
|
/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" macos/Runner/Info.plist
|
|
/usr/libexec/PlistBuddy -c "Print :CFBundleVersion" macos/Runner/Info.plist
|
|
|
|
- name: Generate per-module secrets.g.dart files (all empty defaults)
|
|
run: |
|
|
set -euo pipefail
|
|
flutter pub get
|
|
dart run tool/generate_new_secrets.dart
|
|
|
|
- name: Pin crypto secrets (never ship random salts)
|
|
env:
|
|
CRYPTO_SALT: ${{ secrets.CRYPTO_SALT }}
|
|
CRYPTO_KEYCHAIN_SALT: ${{ secrets.CRYPTO_KEYCHAIN_SALT }}
|
|
CRYPTO_KEY: ${{ secrets.CRYPTO_KEY }}
|
|
CRYPTO_WALLET_SALT: ${{ secrets.CRYPTO_WALLET_SALT }}
|
|
CRYPTO_SHORT_KEY: ${{ secrets.CRYPTO_SHORT_KEY }}
|
|
CRYPTO_BACKUP_SALT: ${{ secrets.CRYPTO_BACKUP_SALT }}
|
|
CRYPTO_BACKUP_KEYCHAIN_SALT: ${{ secrets.CRYPTO_BACKUP_KEYCHAIN_SALT }}
|
|
CRYPTO_WALLET_GROUP_SALT: ${{ secrets.CRYPTO_WALLET_GROUP_SALT }}
|
|
run: |
|
|
set -euo pipefail
|
|
# generate_new_secrets.dart mints RANDOM salts each run. Shipping
|
|
# those breaks decryption of everything the previous build stored
|
|
# (2026-08-29 "Corrupted seeds" incident). The same pinned values as
|
|
# every other platform, so a wallet moves between them intact.
|
|
for v in CRYPTO_SALT CRYPTO_KEYCHAIN_SALT CRYPTO_KEY CRYPTO_WALLET_SALT CRYPTO_SHORT_KEY CRYPTO_BACKUP_SALT CRYPTO_BACKUP_KEYCHAIN_SALT CRYPTO_WALLET_GROUP_SALT; do
|
|
eval "val=\${$v:-}"
|
|
[ -n "$val" ] || { echo "FATAL: $v is empty — refusing to ship random crypto secrets"; exit 1; }
|
|
done
|
|
sed -i.bak \
|
|
-e "s|const salt = '.*';|const salt = '${CRYPTO_SALT}';|" \
|
|
-e "s|const keychainSalt = '.*';|const keychainSalt = '${CRYPTO_KEYCHAIN_SALT}';|" \
|
|
-e "s|const key = '.*';|const key = '${CRYPTO_KEY}';|" \
|
|
-e "s|const walletSalt = '.*';|const walletSalt = '${CRYPTO_WALLET_SALT}';|" \
|
|
-e "s|const shortKey = '.*';|const shortKey = '${CRYPTO_SHORT_KEY}';|" \
|
|
-e "s|const backupSalt = '.*';|const backupSalt = '${CRYPTO_BACKUP_SALT}';|" \
|
|
-e "s|const backupKeychainSalt = '.*';|const backupKeychainSalt = '${CRYPTO_BACKUP_KEYCHAIN_SALT}';|" \
|
|
-e "s|const walletGroupSalt = '.*';|const walletGroupSalt = '${CRYPTO_WALLET_GROUP_SALT}';|" \
|
|
lib/.secrets.g.dart
|
|
rm -f lib/.secrets.g.dart.bak
|
|
for c in salt keychainSalt key walletSalt shortKey backupSalt backupKeychainSalt walletGroupSalt; do
|
|
grep -qE "^const $c = '[0-9a-f]{8,}';" lib/.secrets.g.dart || { echo "FATAL: $c not pinned"; exit 1; }
|
|
done
|
|
echo "crypto secrets pinned (values not printed)"
|
|
|
|
- name: Inject Trocador affiliate secrets
|
|
env:
|
|
TROCADOR_API_KEY: ${{ secrets.TROCADOR_API_KEY }}
|
|
TROCADOR_MONERO_API_KEY: ${{ secrets.TROCADOR_MONERO_API_KEY }}
|
|
TROCADOR_EXCHANGE_MARKUP: ${{ secrets.TROCADOR_EXCHANGE_MARKUP }}
|
|
run: |
|
|
set -euo pipefail
|
|
sed -i.bak \
|
|
-e "s|const trocadorApiKey = '';|const trocadorApiKey = '${TROCADOR_API_KEY}';|" \
|
|
-e "s|const trocadorMoneroApiKey = '';|const trocadorMoneroApiKey = '${TROCADOR_MONERO_API_KEY}';|" \
|
|
-e "s|const trocadorExchangeMarkup = '';|const trocadorExchangeMarkup = '${TROCADOR_EXCHANGE_MARKUP:-1}';|" \
|
|
lib/.secrets.g.dart
|
|
rm -f lib/.secrets.g.dart.bak
|
|
awk -F"'" '/^const (trocador[A-Za-z]*) = / { split($1, a, " "); v = (length($2) ? "set (" length($2) " chars)" : "EMPTY"); print a[2] ": " v }' lib/.secrets.g.dart # lengths only; values never reach the log
|
|
|
|
- name: Build generated code (mobx + hive adapters)
|
|
run: bash model_generator.sh
|
|
|
|
- name: Generate localization
|
|
run: dart run tool/generate_localization.dart
|
|
|
|
# res/pictures is the source for every compiled brand asset. Skipping this
|
|
# would fall back to raw SVGs, and it is the step whose inputs shipped
|
|
# Cake's artwork in 1.0.3, so it runs here exactly as it does elsewhere.
|
|
- name: Compile SVG assets (res/pictures/*.svg → assets/new-ui/*.svg.vec)
|
|
run: ./compile_graphics.sh
|
|
|
|
# ---- Configure the Xcode project, then build each lane ----------------
|
|
- name: Configure the macOS project
|
|
run: flutter build macos --release --config-only
|
|
|
|
# One throwaway keychain for both lanes, never the login keychain: this is
|
|
# a shared Mac. Each tool is granted only the keys it uses: codesign the
|
|
# application certificates, productbuild the installer one.
|
|
- name: Prepare the signing keychain, profiles and API key
|
|
env:
|
|
APPLE_KEYCHAIN_PASSWORD: ${{ secrets.APPLE_KEYCHAIN_PASSWORD }}
|
|
APPLE_KEY_ID: ${{ secrets.APPLE_KEY_ID }}
|
|
APPLE_API_KEY_P8_BASE64: ${{ secrets.APPLE_API_KEY_P8_BASE64 }}
|
|
MACOS_DEVELOPER_ID_CERT_P12_BASE64: ${{ secrets.MACOS_DEVELOPER_ID_CERT_P12_BASE64 }}
|
|
MACOS_DEVELOPER_ID_CERT_P12_PASSWORD: ${{ secrets.MACOS_DEVELOPER_ID_CERT_P12_PASSWORD }}
|
|
MACOS_DEVELOPER_ID_PROFILE_BASE64: ${{ secrets.MACOS_DEVELOPER_ID_PROFILE_BASE64 }}
|
|
APPLE_DIST_CERT_P12_BASE64: ${{ secrets.APPLE_DIST_CERT_P12_BASE64 }}
|
|
APPLE_DIST_CERT_P12_PASSWORD: ${{ secrets.APPLE_DIST_CERT_P12_PASSWORD }}
|
|
MACOS_INSTALLER_CERT_P12_BASE64: ${{ secrets.MACOS_INSTALLER_CERT_P12_BASE64 }}
|
|
MACOS_INSTALLER_CERT_P12_PASSWORD: ${{ secrets.MACOS_INSTALLER_CERT_P12_PASSWORD }}
|
|
MACOS_APP_STORE_PROFILE_BASE64: ${{ secrets.MACOS_APP_STORE_PROFILE_BASE64 }}
|
|
run: |
|
|
set -euo pipefail
|
|
umask 077
|
|
S="$RUNNER_TEMP/macos-signing"
|
|
rm -rf "$S"; mkdir -p "$S/private_keys"
|
|
# A job killed before cleanup leaves the keychain behind, and
|
|
# create-keychain then refuses every later run until someone deletes it.
|
|
security delete-keychain "$KEYCHAIN" 2>/dev/null || true
|
|
security create-keychain -p "$APPLE_KEYCHAIN_PASSWORD" "$KEYCHAIN"
|
|
security set-keychain-settings -lut 21600 "$KEYCHAIN"
|
|
security unlock-keychain -p "$APPLE_KEYCHAIN_PASSWORD" "$KEYCHAIN"
|
|
import() { # <base64> <password> <tool>
|
|
printf '%s' "$1" | base64 --decode > "$S/cert.p12"
|
|
security import "$S/cert.p12" -k "$KEYCHAIN" -P "$2" -T "$3"
|
|
rm -f "$S/cert.p12"
|
|
}
|
|
if [ "$LANES" != app-store ]; then
|
|
import "$MACOS_DEVELOPER_ID_CERT_P12_BASE64" "$MACOS_DEVELOPER_ID_CERT_P12_PASSWORD" /usr/bin/codesign
|
|
printf '%s' "$MACOS_DEVELOPER_ID_PROFILE_BASE64" | base64 --decode > "$S/developer-id.provisionprofile"
|
|
fi
|
|
if [ "$LANES" != developer-id ]; then
|
|
import "$APPLE_DIST_CERT_P12_BASE64" "$APPLE_DIST_CERT_P12_PASSWORD" /usr/bin/codesign
|
|
import "$MACOS_INSTALLER_CERT_P12_BASE64" "$MACOS_INSTALLER_CERT_P12_PASSWORD" /usr/bin/productbuild
|
|
printf '%s' "$MACOS_APP_STORE_PROFILE_BASE64" | base64 --decode > "$S/app-store.provisionprofile"
|
|
fi
|
|
security set-key-partition-list -S apple-tool:,apple: -s -k "$APPLE_KEYCHAIN_PASSWORD" "$KEYCHAIN" >/dev/null
|
|
# Prepend rather than replace, so the runner keeps every keychain it had.
|
|
EXISTING="$(security list-keychains -d user | sed -e 's/^[[:space:]]*"//' -e 's/"$//')"
|
|
# shellcheck disable=SC2086
|
|
security list-keychains -d user -s "$KEYCHAIN" $EXISTING
|
|
security find-identity -v "$KEYCHAIN" | sed -n 's/.*"\(.*\)".*/identity: \1/p'
|
|
# Under the job's temp dir, not the runner home: altool finds it through
|
|
# API_PRIVATE_KEYS_DIR, and nothing is left on the shared disk.
|
|
printf '%s' "$APPLE_API_KEY_P8_BASE64" | base64 --decode > "$S/private_keys/AuthKey_${APPLE_KEY_ID}.p8"
|
|
|
|
- name: Build the Developer ID app
|
|
id: devid
|
|
if: env.LANES != 'app-store'
|
|
run: |
|
|
set -euo pipefail
|
|
APP="$(scripts/macos/build_signed.sh "$RUNNER_TEMP/macos-signing/developer-id.provisionprofile" \
|
|
"$KEYCHAIN" build/macos-developer-id ENABLE_HARDENED_RUNTIME=YES | tail -1)"
|
|
echo "app=$APP" >> "$GITHUB_OUTPUT"
|
|
|
|
- name: Notarize and staple the Developer ID app
|
|
if: env.LANES != 'app-store'
|
|
env:
|
|
APP: ${{ steps.devid.outputs.app }}
|
|
APPLE_KEY_ID: ${{ secrets.APPLE_KEY_ID }}
|
|
APPLE_ISSUER_ID: ${{ secrets.APPLE_ISSUER_ID }}
|
|
run: |
|
|
set -euo pipefail
|
|
ZIP="$RUNNER_TEMP/notarize.zip"
|
|
# ditto, not zip: it keeps the bundle's symlinks and extended attributes.
|
|
ditto -c -k --keepParent "$APP" "$ZIP"
|
|
xcrun notarytool submit "$ZIP" \
|
|
--key "$RUNNER_TEMP/macos-signing/private_keys/AuthKey_${APPLE_KEY_ID}.p8" \
|
|
--key-id "$APPLE_KEY_ID" --issuer "$APPLE_ISSUER_ID" \
|
|
--wait --timeout 45m
|
|
rm -f "$ZIP"
|
|
xcrun stapler staple "$APP"
|
|
xcrun stapler validate "$APP"
|
|
# What a user's Mac will decide on first launch, asked of this one.
|
|
spctl --assess --type execute --verbose=2 "$APP"
|
|
|
|
- name: Build the App Store package
|
|
id: mas
|
|
if: env.LANES != 'developer-id'
|
|
run: |
|
|
set -euo pipefail
|
|
APP="$(scripts/macos/build_signed.sh "$RUNNER_TEMP/macos-signing/app-store.provisionprofile" \
|
|
"$KEYCHAIN" build/macos-app-store | tail -1)"
|
|
# Matched by name: a hardcoded hash would break on the annual renewal.
|
|
# -p codesigning is absent on purpose; an installer identity is not one.
|
|
INSTALLER_ID="$(security find-identity -v "$KEYCHAIN" |
|
|
sed -n 's/.*"\(3rd Party Mac Developer Installer:[^"]*\)".*/\1/p' | head -1)"
|
|
[ -n "$INSTALLER_ID" ] || {
|
|
echo "FATAL: no 3rd Party Mac Developer Installer identity in the keychain."
|
|
echo "MACOS_INSTALLER_CERT_P12_BASE64 imported, so it most likely holds an"
|
|
echo "application certificate; its CN should read Installer."
|
|
exit 1; }
|
|
mkdir -p dist
|
|
PKG="dist/app-store.pkg"
|
|
productbuild --component "$APP" /Applications \
|
|
--sign "$INSTALLER_ID" --keychain "$KEYCHAIN" "$PKG"
|
|
pkgutil --check-signature "$PKG"
|
|
echo "pkg=$PKG" >> "$GITHUB_OUTPUT"
|
|
|
|
- name: Upload the App Store package to App Store Connect
|
|
if: env.LANES != 'developer-id' && env.APP_STORE_UPLOAD == 'true'
|
|
env:
|
|
PKG: ${{ steps.mas.outputs.pkg }}
|
|
APPLE_KEY_ID: ${{ secrets.APPLE_KEY_ID }}
|
|
APPLE_ISSUER_ID: ${{ secrets.APPLE_ISSUER_ID }}
|
|
run: |
|
|
set -euo pipefail
|
|
API_PRIVATE_KEYS_DIR="$RUNNER_TEMP/macos-signing/private_keys" \
|
|
xcrun altool --upload-app --type macos --file "$PKG" \
|
|
--apiKey "$APPLE_KEY_ID" --apiIssuer "$APPLE_ISSUER_ID"
|
|
|
|
- name: Name the outputs after the version they carry
|
|
id: package
|
|
env:
|
|
DEVID_APP: ${{ steps.devid.outputs.app }}
|
|
MAS_PKG: ${{ steps.mas.outputs.pkg }}
|
|
run: |
|
|
set -euo pipefail
|
|
FULL=$(awk '/^version:/ {print $2; exit}' pubspec_description.yaml)
|
|
VER="${FULL%%+*}"; BUILD="${FULL#*+}"
|
|
if [ -z "$VER" ] || [ "$BUILD" = "$FULL" ]; then
|
|
echo "pubspec_description.yaml version '$FULL' is not <name>+<build>; refusing to guess a name" >&2
|
|
exit 1
|
|
fi
|
|
BASE="hash-bags-${VER}-build${BUILD}-macos"
|
|
mkdir -p dist
|
|
if [ -n "${DEVID_APP:-}" ]; then
|
|
ditto -c -k --keepParent "$DEVID_APP" "dist/${BASE}.zip"
|
|
echo "zip=dist/${BASE}.zip" >> "$GITHUB_OUTPUT"
|
|
fi
|
|
if [ -n "${MAS_PKG:-}" ]; then
|
|
mv "$MAS_PKG" "dist/${BASE}-app-store.pkg"
|
|
fi
|
|
(cd dist && shasum -a 256 hash-bags-* | tee SHA256SUMS)
|
|
echo "base=$BASE" >> "$GITHUB_OUTPUT"
|
|
|
|
- name: Upload artifact
|
|
uses: actions/upload-artifact@v3
|
|
with:
|
|
name: ${{ steps.package.outputs.base }}-${{ github.sha }}
|
|
path: dist/
|
|
retention-days: 14
|
|
|
|
- name: Resolve the release tag
|
|
id: reltag
|
|
run: |
|
|
set -euo pipefail
|
|
if [ "${GITHUB_REF:-}" != "${GITHUB_REF#refs/tags/}" ]; then
|
|
TAG="${GITHUB_REF#refs/tags/}"
|
|
else
|
|
TAG="${{ github.event.inputs.release_tag }}"
|
|
fi
|
|
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
|
if [ -n "$TAG" ]; then echo "publishing to $TAG"; else echo "no tag; build only"; fi
|
|
|
|
# Only the Developer ID zip is a download; the App Store package goes to Apple.
|
|
- name: Publish to the GitHub release
|
|
if: steps.reltag.outputs.tag != '' && steps.package.outputs.zip != ''
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GH_RELEASE_TOKEN }}
|
|
TAG: ${{ steps.reltag.outputs.tag }}
|
|
FILE: ${{ steps.package.outputs.zip }}
|
|
run: |
|
|
set -euo pipefail
|
|
if [ -z "${GH_TOKEN:-}" ]; then
|
|
echo "GH_RELEASE_TOKEN not set; skipping the GitHub upload"
|
|
exit 0
|
|
fi
|
|
REPO="Such-Software/hash-wallet"
|
|
API="https://api.github.com/repos/$REPO"
|
|
AUTH="Authorization: Bearer $GH_TOKEN"
|
|
NAME="$(basename "$FILE")"
|
|
code=$(curl -sS -o rel-gh.json -w '%{http_code}' -H "$AUTH" "$API/releases/tags/$TAG" || true)
|
|
if [ "$code" != "200" ]; then
|
|
echo "no GitHub release for $TAG (HTTP $code); skipping"
|
|
exit 0
|
|
fi
|
|
ID=$(python3 -c 'import json;print(json.load(open("rel-gh.json"))["id"])')
|
|
for oid in $(python3 -c 'import json,sys;d=json.load(open("rel-gh.json"));print(" ".join(str(a["id"]) for a in d.get("assets",[]) if a["name"]==sys.argv[1]))' "$NAME"); do
|
|
curl -sS -o /dev/null -X DELETE -H "$AUTH" "$API/releases/assets/$oid" || true
|
|
done
|
|
curl -sS --fail-with-body -o /dev/null -X POST -H "$AUTH" \
|
|
-H "Content-Type: application/octet-stream" \
|
|
--data-binary "@$FILE" \
|
|
"https://uploads.github.com/repos/$REPO/releases/$ID/assets?name=$NAME"
|
|
echo "uploaded $NAME to the GitHub release $TAG"
|
|
|
|
- name: Clean up the keychain, profiles and API key
|
|
if: always()
|
|
run: |
|
|
security delete-keychain "$KEYCHAIN" 2>/dev/null || true
|
|
rm -rf "$RUNNER_TEMP/macos-signing"
|