burrow/Scripts/ci/package-apple-artifacts.sh
Conrad Kramer a84922f4f3
Some checks failed
Build Rust / Cargo Test (push) Successful in 4m2s
Build Site / Next.js Build (push) Failing after 4s
Lint Governance / BEP Metadata (push) Successful in 1s
Fix rcodesign notary key output
2026-06-07 15:53:15 -07:00

587 lines
18 KiB
Bash
Executable file

#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$repo_root"
platform="${1:-all}"
build_number="${BUILD_NUMBER:-${RELEASE_BUILD_NUMBER:-${RELEASE_REF:-manual}}}"
out_root="${BURROW_RELEASE_OUT:-${repo_root}/dist/builds/${build_number}}"
apple_root="${out_root}/apple"
archives_root="${apple_root}/archives"
derived_data="${BURROW_DERIVED_DATA_PATH:-${repo_root}/.derived-data/apple}"
source_packages="${BURROW_SWIFTPM_CACHE_PATH:-${XDG_CACHE_HOME:-${HOME}/.cache}/burrow/swiftpm}"
project="Apple/Burrow.xcodeproj"
scheme="${BURROW_APPLE_SCHEME:-App}"
bundle_id="${BURROW_APPLE_BUNDLE_ID:-net.burrow.app}"
network_extension_bundle_id="${BURROW_APPLE_NETWORK_EXTENSION_BUNDLE_ID:-${bundle_id}.network}"
signing_ready="${BURROW_APPLE_SIGNING_READY:-0}"
signing_mode="${BURROW_APPLE_SIGNING_MODE:-keychain}"
require_signing="${BURROW_APPLE_REQUIRE_SIGNING:-false}"
sparkle_feed_url="${BURROW_SPARKLE_FEED_URL:-https://releases.burrow.net/sparkle/appcast.xml}"
sparkle_public_ed_key="${BURROW_SPARKLE_PUBLIC_ED_KEY:-Myv9ZNZT6YGKMtMezh52ra4WqaeEKc4VlvVU0evhJeI=}"
sparkle_sign_with_kms="${BURROW_SPARKLE_SIGN_WITH_KMS:-false}"
notarize_macos="${BURROW_NOTARIZE_MACOS:-false}"
mkdir -p "$apple_root" "$archives_root" "$derived_data" "$source_packages"
truthy() {
case "${1:-}" in
1|true|TRUE|True|yes|YES|Yes|y|Y|on|ON|On) return 0 ;;
*) return 1 ;;
esac
}
sha256_file() {
local file="$1"
if command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$file" > "${file}.sha256"
else
sha256sum "$file" > "${file}.sha256"
fi
}
safe_profile_slug() {
python3 - "$1" <<'PY'
import re
import sys
print(re.sub(r"[^A-Za-z0-9]", "", sys.argv[1]).lower())
PY
}
profile_for() {
local identifier="$1"
local suffix="$2"
local slug path
slug="$(safe_profile_slug "$identifier")"
for path in \
".forgejo/actions/export/${slug}${suffix}.provisionprofile" \
".forgejo/actions/export/${slug}${suffix}.mobileprovision" \
"Apple/Profiles/${slug}${suffix}.provisionprofile" \
"Apple/Profiles/${slug}${suffix}.mobileprovision"; do
if [[ -s "$path" ]]; then
printf '%s\n' "$path"
return 0
fi
done
return 1
}
generate_entitlements_from_profile() {
local profile="$1"
local output="$2"
Scripts/apple/profile-entitlements.py "$profile" --output "$output"
}
apple_signing_available() {
if [[ "$signing_mode" == "google-kms" || "$signing_mode" == "google-kms-staged" || "$signing_mode" == "kms" ]]; then
return 0
fi
[[ "$signing_ready" == "1" ]]
}
kms_sign_bundle() {
local family="$1"
local bundle_path="$2"
local entitlements="$3"
local runtime_flag=()
if [[ "$family" == "developer-id" ]]; then
runtime_flag=(--runtime)
fi
Scripts/apple/sign-with-google-kms.sh \
--family "$family" \
--path "$bundle_path" \
--entitlements "$entitlements" \
"${runtime_flag[@]}"
}
kms_sign_file() {
local family="$1"
local file_path="$2"
local runtime_flag=()
if [[ "$family" == "developer-id" ]]; then
runtime_flag=(--runtime)
fi
Scripts/apple/sign-with-google-kms.sh \
--family "$family" \
--path "$file_path" \
"${runtime_flag[@]}"
}
write_version() {
mkdir -p Apple/Configuration
printf 'CURRENT_PROJECT_VERSION = %s\n' "$build_number" > Apple/Configuration/Version.xcconfig
}
xcode_common_args=(
-project "$project"
-scheme "$scheme"
-configuration Release
-derivedDataPath "$derived_data"
-clonedSourcePackagesDirPath "$source_packages"
CURRENT_PROJECT_VERSION="$build_number"
APP_BUNDLE_IDENTIFIER="$bundle_id"
NETWORK_EXTENSION_BUNDLE_IDENTIFIER="$network_extension_bundle_id"
BURROW_SPARKLE_FEED_URL="$sparkle_feed_url"
BURROW_SPARKLE_PUBLIC_ED_KEY="$sparkle_public_ed_key"
)
xcode_auth_args=()
if [[ -n "${ASC_API_KEY_ID:-}" && -n "${ASC_API_ISSUER_ID:-}" && -n "${ASC_API_KEY_PATH:-}" && -f "${ASC_API_KEY_PATH:-}" ]]; then
xcode_auth_args+=(
-authenticationKeyID "$ASC_API_KEY_ID"
-authenticationKeyIssuerID "$ASC_API_ISSUER_ID"
-authenticationKeyPath "$ASC_API_KEY_PATH"
)
fi
if [[ -n "${BURROW_APPLE_KEYCHAIN_PATH:-}" ]]; then
xcode_common_args+=(OTHER_CODE_SIGN_FLAGS="--keychain ${BURROW_APPLE_KEYCHAIN_PATH}")
fi
require_xcodebuild() {
if ! command -v xcodebuild >/dev/null 2>&1; then
echo "xcodebuild is required for Apple release artifacts" >&2
exit 1
fi
xcodebuild -version
xcrun --find swiftc >/dev/null
}
unsigned_note() {
local platform_name="$1"
local artifact_name="$2"
{
echo "Burrow ${platform_name} signing assets were not available."
echo
echo "The lane built an unsigned validation artifact instead of an uploadable release artifact."
echo "Seal the Apple release credentials with agenix and let CI sync provisioning profiles from App Store Connect to produce ${artifact_name}."
} > "${apple_root}/README-${platform_name}-unsigned.txt"
}
ensure_signing_or_note() {
local platform_name="$1"
local artifact_name="$2"
if apple_signing_available; then
return 0
fi
if truthy "$require_signing"; then
echo "Apple signing is required for ${platform_name}, but signing assets are not configured." >&2
exit 1
fi
unsigned_note "$platform_name" "$artifact_name"
return 1
}
write_export_options() {
local method="$1"
local path="$2"
cat > "$path" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key>
<string>${method}</string>
<key>destination</key>
<string>export</string>
<key>signingStyle</key>
<string>automatic</string>
<key>stripSwiftSymbols</key>
<true/>
<key>teamID</key>
<string>${DEVELOPMENT_TEAM:-P6PV2R9443}</string>
</dict>
</plist>
EOF
}
build_ios_unsigned() {
local product_dir zip_path
xcodebuild build \
"${xcode_common_args[@]}" \
-destination "generic/platform=iOS Simulator" \
ARCHS=arm64 \
ONLY_ACTIVE_ARCH=YES \
CODE_SIGNING_ALLOWED=NO \
CODE_SIGNING_REQUIRED=NO
product_dir="${derived_data}/Build/Products/Release-iphonesimulator"
zip_path="${apple_root}/Burrow-iOS-simulator-${build_number}.unsigned.zip"
if [[ -d "${product_dir}/Burrow.app" ]]; then
(cd "$product_dir" && zip -qr "$zip_path" Burrow.app)
sha256_file "$zip_path"
fi
}
build_ios_release() {
local archive_path export_dir export_options ipa
if ! ensure_signing_or_note "iOS" "Burrow-iOS-${build_number}.ipa"; then
build_ios_unsigned
return 0
fi
if [[ "$signing_mode" == "google-kms-staged" ]]; then
build_ios_kms_staged
return 0
fi
if [[ "$signing_mode" == "google-kms" || "$signing_mode" == "kms" ]]; then
build_ios_kms_release
return 0
fi
archive_path="${archives_root}/Burrow-iOS-${build_number}.xcarchive"
export_dir="${apple_root}/ios-export"
export_options="${apple_root}/ExportOptions-iOS.plist"
write_export_options "app-store-connect" "$export_options"
xcodebuild archive \
"${xcode_common_args[@]}" \
-destination "generic/platform=iOS" \
-archivePath "$archive_path" \
ARCHS=arm64 \
-allowProvisioningUpdates \
"${xcode_auth_args[@]}"
xcodebuild -exportArchive \
-archivePath "$archive_path" \
-exportPath "$export_dir" \
-exportOptionsPlist "$export_options" \
-allowProvisioningUpdates \
"${xcode_auth_args[@]}"
ipa="$(find "$export_dir" -maxdepth 1 -type f -name '*.ipa' | sort | head -n 1 || true)"
if [[ -z "$ipa" ]]; then
echo "iOS export completed but did not produce an IPA" >&2
exit 1
fi
cp "$ipa" "${apple_root}/Burrow-iOS-${build_number}.ipa"
sha256_file "${apple_root}/Burrow-iOS-${build_number}.ipa"
}
build_ios_kms_staged() {
local product_dir source_app stage_dir ipa
xcodebuild build \
"${xcode_common_args[@]}" \
-destination "generic/platform=iOS" \
ARCHS=arm64 \
ONLY_ACTIVE_ARCH=YES \
SKIP_INSTALL=NO \
CODE_SIGNING_ALLOWED=NO \
CODE_SIGNING_REQUIRED=NO \
CODE_SIGN_IDENTITY=
product_dir="${derived_data}/Build/Products/Release-iphoneos"
source_app="${product_dir}/Burrow.app"
if [[ ! -d "$source_app" ]]; then
echo "iOS build completed but did not produce Burrow.app" >&2
exit 1
fi
stage_dir="${apple_root}/ios-unsigned/Payload"
rm -rf "$stage_dir"
mkdir -p "$stage_dir"
ditto "$source_app" "${stage_dir}/Burrow.app"
ipa="${apple_root}/Burrow-iOS-${build_number}.unsigned.ipa"
rm -f "$ipa"
(cd "${apple_root}/ios-unsigned" && zip -qry "$ipa" Payload)
sha256_file "$ipa"
}
build_ios_kms_release() {
local product_dir source_app stage_dir app ext app_profile ext_profile app_entitlements ext_entitlements ipa
xcodebuild build \
"${xcode_common_args[@]}" \
-destination "generic/platform=iOS" \
ARCHS=arm64 \
ONLY_ACTIVE_ARCH=YES \
SKIP_INSTALL=NO \
CODE_SIGNING_ALLOWED=NO \
CODE_SIGNING_REQUIRED=NO \
CODE_SIGN_IDENTITY=
product_dir="${derived_data}/Build/Products/Release-iphoneos"
source_app="${product_dir}/Burrow.app"
if [[ ! -d "$source_app" ]]; then
echo "iOS build completed but did not produce Burrow.app" >&2
exit 1
fi
stage_dir="${apple_root}/ios-kms/Payload"
rm -rf "$stage_dir"
mkdir -p "$stage_dir"
ditto "$source_app" "${stage_dir}/Burrow.app"
app="${stage_dir}/Burrow.app"
ext="${app}/PlugIns/BurrowNetworkExtension.appex"
if [[ ! -d "$ext" ]]; then
echo "iOS app is missing BurrowNetworkExtension.appex" >&2
exit 1
fi
app_profile="$(profile_for "$bundle_id" "appstore")" || {
echo "missing iOS App Store provisioning profile for ${bundle_id}" >&2
exit 1
}
ext_profile="$(profile_for "$network_extension_bundle_id" "appstore")" || {
echo "missing iOS App Store provisioning profile for ${network_extension_bundle_id}" >&2
exit 1
}
cp "$app_profile" "${app}/embedded.mobileprovision"
cp "$ext_profile" "${ext}/embedded.mobileprovision"
app_entitlements="${apple_root}/ios-kms/app-entitlements.plist"
ext_entitlements="${apple_root}/ios-kms/extension-entitlements.plist"
generate_entitlements_from_profile "$app_profile" "$app_entitlements"
generate_entitlements_from_profile "$ext_profile" "$ext_entitlements"
rm -rf "${app}/_CodeSignature" "${ext}/_CodeSignature"
kms_sign_bundle ios "$ext" "$ext_entitlements"
kms_sign_bundle ios "$app" "$app_entitlements"
ipa="${apple_root}/Burrow-iOS-${build_number}.ipa"
rm -f "$ipa"
(cd "${apple_root}/ios-kms" && zip -qry "$ipa" Payload)
sha256_file "$ipa"
}
build_macos_unsigned() {
local product_dir zip_path
xcodebuild build \
"${xcode_common_args[@]}" \
-destination "platform=macOS" \
CODE_SIGNING_ALLOWED=NO \
CODE_SIGNING_REQUIRED=NO
product_dir="${derived_data}/Build/Products/Release"
zip_path="${apple_root}/Burrow-macOS-${build_number}.unsigned.zip"
if [[ -d "${product_dir}/Burrow.app" ]]; then
ditto -c -k --keepParent "${product_dir}/Burrow.app" "$zip_path"
sha256_file "$zip_path"
fi
}
build_macos_release() {
local archive_path export_dir export_options app dmg channel sparkle_dir length pubdate url
if ! ensure_signing_or_note "macOS" "Burrow-macOS-${build_number}.dmg"; then
build_macos_unsigned
return 0
fi
if [[ "$signing_mode" == "google-kms-staged" ]]; then
build_macos_kms_staged
return 0
fi
if [[ "$signing_mode" == "google-kms" || "$signing_mode" == "kms" ]]; then
build_macos_kms_release
return 0
fi
archive_path="${archives_root}/Burrow-macOS-${build_number}.xcarchive"
export_dir="${apple_root}/macos-export"
export_options="${apple_root}/ExportOptions-macOS.plist"
write_export_options "${BURROW_MACOS_EXPORT_METHOD:-developer-id}" "$export_options"
xcodebuild archive \
"${xcode_common_args[@]}" \
-destination "generic/platform=macOS" \
-archivePath "$archive_path" \
-allowProvisioningUpdates \
"${xcode_auth_args[@]}"
xcodebuild -exportArchive \
-archivePath "$archive_path" \
-exportPath "$export_dir" \
-exportOptionsPlist "$export_options" \
-allowProvisioningUpdates \
"${xcode_auth_args[@]}"
app="$(find "$export_dir" -maxdepth 2 -type d -name 'Burrow.app' | sort | head -n 1 || true)"
if [[ -z "$app" ]]; then
echo "macOS export completed but did not produce Burrow.app" >&2
exit 1
fi
dmg="${apple_root}/Burrow-macOS-${build_number}.dmg"
rm -f "$dmg"
hdiutil create -volname "Burrow ${build_number}" -srcfolder "$app" -ov -format UDZO "$dmg"
sha256_file "$dmg"
channel="${SPARKLE_CHANNEL:-build-${build_number}}"
sparkle_dir="${out_root}/sparkle/${channel}"
mkdir -p "$sparkle_dir"
cp "$dmg" "$sparkle_dir/"
length="$(wc -c < "$dmg" | tr -d ' ')"
pubdate="$(LC_ALL=C date -u '+%a, %d %b %Y %H:%M:%S +0000')"
url="${SPARKLE_DOWNLOAD_PREFIX:-https://releases.burrow.net/sparkle}/${channel}/$(basename "$dmg")"
cat > "${sparkle_dir}/appcast.xml" <<EOF
<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle">
<channel>
<title>Burrow ${channel}</title>
<item>
<title>Burrow ${build_number}</title>
<pubDate>${pubdate}</pubDate>
<sparkle:version>${build_number}</sparkle:version>
<sparkle:shortVersionString>0.1</sparkle:shortVersionString>
<enclosure url="${url}" sparkle:version="${build_number}" length="${length}" type="application/octet-stream" />
</item>
</channel>
</rss>
EOF
if truthy "$sparkle_sign_with_kms"; then
Scripts/sparkle/sign-appcast-kms.py \
--appcast "${sparkle_dir}/appcast.xml" \
--artifact-dir "$sparkle_dir"
fi
}
build_macos_kms_staged() {
local product_dir source_app zip_path
xcodebuild build \
"${xcode_common_args[@]}" \
-destination "platform=macOS" \
CODE_SIGNING_ALLOWED=NO \
CODE_SIGNING_REQUIRED=NO \
CODE_SIGN_IDENTITY=
product_dir="${derived_data}/Build/Products/Release"
source_app="${product_dir}/Burrow.app"
if [[ ! -d "$source_app" ]]; then
echo "macOS build completed but did not produce Burrow.app" >&2
exit 1
fi
zip_path="${apple_root}/Burrow-macOS-${build_number}.unsigned.zip"
rm -f "$zip_path"
ditto -c -k --keepParent "$source_app" "$zip_path"
sha256_file "$zip_path"
}
write_sparkle_appcast() {
local dmg="$1"
local channel sparkle_dir length pubdate url
channel="${SPARKLE_CHANNEL:-build-${build_number}}"
sparkle_dir="${out_root}/sparkle/${channel}"
mkdir -p "$sparkle_dir"
cp "$dmg" "$sparkle_dir/"
length="$(wc -c < "$dmg" | tr -d ' ')"
pubdate="$(LC_ALL=C date -u '+%a, %d %b %Y %H:%M:%S +0000')"
url="${SPARKLE_DOWNLOAD_PREFIX:-https://releases.burrow.net/sparkle}/${channel}/$(basename "$dmg")"
cat > "${sparkle_dir}/appcast.xml" <<EOF
<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle">
<channel>
<title>Burrow ${channel}</title>
<item>
<title>Burrow ${build_number}</title>
<pubDate>${pubdate}</pubDate>
<sparkle:version>${build_number}</sparkle:version>
<sparkle:shortVersionString>0.1</sparkle:shortVersionString>
<enclosure url="${url}" sparkle:version="${build_number}" length="${length}" type="application/octet-stream" />
</item>
</channel>
</rss>
EOF
if truthy "$sparkle_sign_with_kms"; then
Scripts/sparkle/sign-appcast-kms.py \
--appcast "${sparkle_dir}/appcast.xml" \
--artifact-dir "$sparkle_dir"
fi
}
notarize_dmg_if_requested() {
local dmg="$1"
local key_json
if ! truthy "$notarize_macos"; then
return 0
fi
if [[ -z "${ASC_API_KEY_ID:-}" || -z "${ASC_API_ISSUER_ID:-}" || -z "${ASC_API_KEY_PATH:-}" ]]; then
echo "macOS notarization requires ASC_API_KEY_ID, ASC_API_ISSUER_ID, and ASC_API_KEY_PATH" >&2
exit 1
fi
key_json="${apple_root}/notary-api-key.json"
rcodesign encode-app-store-connect-api-key \
--output-path "$key_json" \
"$ASC_API_ISSUER_ID" \
"$ASC_API_KEY_ID" \
"$ASC_API_KEY_PATH"
rcodesign notary-submit --api-key-file "$key_json" --wait --staple "$dmg"
rm -f "$key_json"
}
build_macos_kms_release() {
local product_dir source_app app ext app_profile ext_profile app_entitlements ext_entitlements dmg
xcodebuild build \
"${xcode_common_args[@]}" \
-destination "platform=macOS" \
CODE_SIGNING_ALLOWED=NO \
CODE_SIGNING_REQUIRED=NO \
CODE_SIGN_IDENTITY=
product_dir="${derived_data}/Build/Products/Release"
source_app="${product_dir}/Burrow.app"
if [[ ! -d "$source_app" ]]; then
echo "macOS build completed but did not produce Burrow.app" >&2
exit 1
fi
app="${apple_root}/macos-kms/Burrow.app"
rm -rf "$app"
mkdir -p "$(dirname "$app")"
ditto "$source_app" "$app"
ext="${app}/Contents/PlugIns/BurrowNetworkExtension.appex"
app_profile="$(profile_for "$bundle_id" "developerid")" || {
echo "missing macOS Developer ID provisioning profile for ${bundle_id}" >&2
exit 1
}
cp "$app_profile" "${app}/Contents/embedded.provisionprofile"
app_entitlements="${apple_root}/macos-kms/app-entitlements.plist"
generate_entitlements_from_profile "$app_profile" "$app_entitlements"
if [[ -d "$ext" ]]; then
ext_profile="$(profile_for "$network_extension_bundle_id" "developerid")" || {
echo "missing macOS Developer ID provisioning profile for ${network_extension_bundle_id}" >&2
exit 1
}
cp "$ext_profile" "${ext}/Contents/embedded.provisionprofile"
ext_entitlements="${apple_root}/macos-kms/extension-entitlements.plist"
generate_entitlements_from_profile "$ext_profile" "$ext_entitlements"
rm -rf "${ext}/Contents/_CodeSignature"
kms_sign_bundle developer-id "$ext" "$ext_entitlements"
fi
rm -rf "${app}/Contents/_CodeSignature"
kms_sign_bundle developer-id "$app" "$app_entitlements"
dmg="${apple_root}/Burrow-macOS-${build_number}.dmg"
rm -f "$dmg"
hdiutil create -volname "Burrow ${build_number}" -srcfolder "$app" -ov -format UDZO "$dmg"
kms_sign_file developer-id "$dmg"
notarize_dmg_if_requested "$dmg"
sha256_file "$dmg"
write_sparkle_appcast "$dmg"
}
require_xcodebuild
write_version
case "$platform" in
all)
build_ios_release
build_macos_release
;;
ios)
build_ios_release
;;
macos)
build_macos_release
;;
*)
echo "unknown Apple release platform: $platform" >&2
exit 64
;;
esac
find "$out_root" -type f -print | sort