Files
hash-wallet/.github/workflows/build-macos.yml
T
jwinterm d0f4e20b29 ci(macos): sign without get-task-allow, and show the notary log on refusal
The first notarized submission came back Invalid: xcodebuild build injects
com.apple.security.get-task-allow into the signature, which archive would
have stripped and which notarization and App Store review both reject.
build_signed.sh now passes CODE_SIGN_INJECT_BASE_ENTITLEMENTS=NO and
refuses an app still signed with it, before any upload.

notarytool exits 0 on Invalid, and the step learned the result only when
stapling failed with an unrelated-looking CloudKit error. It now reads the
status, and on anything but Accepted prints Apple's log and names the
submission.
2026-10-27 16:10:23 -04:00

546 lines
27 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' }}
# Gitea hands a dispatch boolean over as a real false, which `|| 'true'`
# would turn back into true; format() keeps it as the string 'false'.
APP_STORE_UPLOAD: ${{ github.event_name == 'workflow_dispatch' && format('{0}', 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
# The tarball is packed on Linux with the framework's symlinks stored
# as copies, which codesign refuses as an ambiguous bundle.
scripts/macos/relink_framework.sh \
scripts/torch_dart/macos/LibTorch.xcframework/macos-arm64_x86_64/LibTorch.framework
- 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"
auth=(--key "$RUNNER_TEMP/macos-signing/private_keys/AuthKey_${APPLE_KEY_ID}.p8"
--key-id "$APPLE_KEY_ID" --issuer "$APPLE_ISSUER_ID")
# notarytool exits 0 on Invalid too; the status decides, and a refusal
# prints Apple's own log, which names each offending binary.
xcrun notarytool submit "$ZIP" "${auth[@]}" --wait --timeout 45m \
--output-format json > "$RUNNER_TEMP/notary.json"
rm -f "$ZIP"
id="$(plutil -extract id raw "$RUNNER_TEMP/notary.json")"
status="$(plutil -extract status raw "$RUNNER_TEMP/notary.json")"
echo "notary submission $id: $status"
if [ "$status" != "Accepted" ]; then
xcrun notarytool log "$id" "${auth[@]}" || true
echo "FATAL: the notary service returned '$status' for submission $id"
exit 1
fi
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"