Skip to content

build-nym-vpn-apple #1663

build-nym-vpn-apple

build-nym-vpn-apple #1663

name: build-nym-vpn-apple
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
paths:
- ".github/workflows/build-nym-vpn-apple.yml"
- "nym-vpn-apple/**"
workflow_dispatch:
inputs:
release_type:
description: |
Select build mode:
🚜 pr — Pull Request
🧪 qa — QA Release
🚢 ship — Ship Release
type: choice
options:
- pr
- qa
- ship
default: "pr"
build_ios:
description: "📱 iOS app (only applies for QA/Ship builds)"
required: false
type: boolean
default: false
build_macos:
description: "🖥️ macOS app (only applies for QA/Ship builds)"
required: false
type: boolean
default: false
workflow_call:
inputs:
release_type:
description: "Build type: pr | qa | ship (raw or labeled)"
type: string
default: "pr"
outputs:
RUST_VERSION:
value: ${{ jobs.build-apple.outputs.RUST_VERSION }}
schedule:
- cron: "0 1 * * *"
env:
CARGO_TERM_COLOR: always
UPLOAD_DIR_IOS: ios_artifacts
RAW_RELEASE_TYPE: ${{ (github.event_name == 'pull_request' && 'pr') || inputs.release_type || 'pr' }}
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
build-apple:
if: github.actor != 'dependabot[bot]'
runs-on: AppleSilicon
timeout-minutes: 90
outputs:
UPLOAD_DIR_IOS: ${{ env.UPLOAD_DIR_IOS }}
RUST_VERSION: ${{ steps.rust-version.outputs.rustc }}
steps:
- name: Checkout repo
uses: actions/checkout@v6
with:
fetch-depth: 1
- name: Dotenv Action
uses: xom9ikk/dotenv@v2.4.0
- name: Normalize release_type
run: |
set -euo pipefail
echo "🔍 Normalizing release type and platform flags..."
echo "--------------------------------------------------"
sel="${{ env.RAW_RELEASE_TYPE }}"
ios="${{ inputs.build_ios || 'false' }}"
macos="${{ inputs.build_macos || 'false' }}"
# Determine normalized mode (pr / qa / ship)
case "$sel" in
pr|PR) mode=pr ;;
qa|QA) mode=qa ;;
ship|SHIP) mode=ship ;;
*) mode=pr ;;
esac
# If triggered by schedule, force QA and build both platforms
if [[ "${GITHUB_EVENT_NAME:-}" == "schedule" ]]; then
mode=qa
ios=true
macos=true
echo "🕒 Scheduled run detected → forcing QA with iOS+macOS"
fi
echo "🧩 Selected mode (raw): $sel"
echo "🧩 Normalized mode: $mode"
echo "📱 Build iOS: $ios"
echo "🖥️ Build macOS: $macos"
# Validate at least one platform selected for QA or Ship
if [[ "$mode" =~ ^(qa|ship)$ ]]; then
if [[ "$ios" != "true" && "$macos" != "true" ]]; then
echo "❌ ERROR: At least one platform (iOS or macOS) must be selected for QA or Ship builds."
exit 1
fi
fi
# Santa's-menu QA code is gated behind `#if SANTA`. Compile it in for
# `qa` only; left OUT of `pr` and `ship` so it is physically absent
# from App Store binaries.
if [[ "$mode" == "qa" ]]; then santa=1; else santa=0; fi
# Export environment variables for later steps
echo "RELEASE_TYPE=$mode" >> "$GITHUB_ENV"
echo "BUILD_IOS=$ios" >> "$GITHUB_ENV"
echo "BUILD_MACOS=$macos" >> "$GITHUB_ENV"
echo "NYM_SANTA=$santa" >> "$GITHUB_ENV"
echo "✅ Environment successfully configured:"
echo " RELEASE_TYPE=$mode"
echo " BUILD_IOS=$ios"
echo " BUILD_MACOS=$macos"
echo " NYM_SANTA=$santa"
echo "--------------------------------------------------"
- name: Show selected release type
run: echo "RELEASE_TYPE=${{ env.RELEASE_TYPE }}"
- name: Install rust toolchain
uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ env.RUST_VERSION }}
components: rustfmt, clippy
targets: x86_64-apple-darwin aarch64-apple-ios aarch64-apple-ios-sim
- name: Install cargo-swift
uses: baptiste0928/cargo-install@v3
with:
crate: cargo-swift
version: ${{ env.CARGO_SWIFT_VERSION }}
- name: Install cargo-license
if: ${{ env.RELEASE_TYPE == 'qa' || env.RELEASE_TYPE == 'ship' }}
uses: baptiste0928/cargo-install@v3
with:
crate: cargo-license
- name: Install Protoc
uses: arduino/setup-protoc@v3
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
- name: Install Go
uses: actions/setup-go@v6
with:
go-version: ${{ env.GOLANG_VERSION }}
cache-dependency-path: wireguard/libwg/go.sum
- name: Get workspace version
id: workspace-version
uses: nicolaiunrein/cargo-get@master
with:
subcommand: workspace.package.version --entry nym-vpn-core
- name: Install cargo-edit
uses: baptiste0928/cargo-install@v3
with:
crate: cargo-edit
- name: Add Homebrew, Ruby, and user Python tools to PATH
run: |
set -euxo pipefail
echo "/opt/homebrew/bin" >> "$GITHUB_PATH"
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
export PATH="/opt/homebrew/bin:$HOME/.local/bin:$PATH"
RUBY_PREFIX="$(brew --prefix ruby)"
echo "${RUBY_PREFIX}/bin" >> "$GITHUB_PATH"
export PATH="${RUBY_PREFIX}/bin:$PATH"
# Ensure xcbeautify is available for xcodebuild output formatting
which xcbeautify || brew install xcbeautify
# Show versions for debugging
brew --version
ruby --version
gem --version
- name: Update prebundled servers
if: ${{ env.RELEASE_TYPE == 'qa' || env.RELEASE_TYPE == 'ship' }}
working-directory: nym-vpn-apple/scripts
run: |
sh UpdatePrebundledServers.sh
- name: Enable QA mode
if: env.RELEASE_TYPE == 'qa'
working-directory: nym-vpn-apple
run: |
if /usr/libexec/PlistBuddy -c "Print :IsCiBuild" "NymVPNDaemon/Resources/Info.plist" >/dev/null 2>&1; then
/usr/libexec/PlistBuddy -c "Set :IsCiBuild true" "NymVPNDaemon/Resources/Info.plist"
else
/usr/libexec/PlistBuddy -c "Add :IsCiBuild string true" "NymVPNDaemon/Resources/Info.plist"
fi
echo "✅ Updated Info.plist contents:"
/usr/libexec/PlistBuddy -c "Print" "NymVPNDaemon/Resources/Info.plist"
- name: Update licences
if: ${{ env.RELEASE_TYPE == 'qa' || env.RELEASE_TYPE == 'ship' }}
run: |
cargo license -j \
--avoid-dev-deps \
--current-dir ./nym-vpn-core \
--filter-platform x86_64-apple-darwin \
--avoid-build-deps \
> ./nym-vpn-apple/NymVPN/Resources/LibLicences.json
- name: Prepare Keychain and import Developer ID certificate
working-directory: nym-vpn-apple
id: setup-keychain
env:
DEVELOPER_ID_CERT_BASE64: ${{ secrets.APPLE_DEVELOPER_ID_APPLICATION_CERT }}
DEVELOPER_ID_CERT_PASS: ${{ secrets.APPLE_DEVELOPER_ID_APPLICATION_CERT_PASS }}
DEVELOPER_ID_INSTALLER_CERT_BASE64: ${{ secrets.APPLE_DEVELOPER_ID_INSTALLER_CERT }}
DEVELOPER_ID_INSTALLER_CERT_PASS: ${{ secrets.APPLE_DEVELOPER_ID_INSTALLER_CERT_PASS }}
run: |
set -euo pipefail
KEYCHAIN_PASSPHRASE=$(echo $(date +%s%N) | sha256sum | head -c 10)
KEYCHAIN_NAME="$(date +%d-%m-%y)_$(echo $(date +%s%N) | sha256sum | head -c 10).keychain-db"
KEYCHAIN_PATH="$HOME/Library/codemagic-cli-tools/keychains/$KEYCHAIN_NAME"
echo "🔐 Initializing Codemagic keychain..."
keychain initialize --path "$KEYCHAIN_PATH" --password "$KEYCHAIN_PASSPHRASE"
keychain show-info
echo "📥 Importing Developer ID certificate..."
echo "$DEVELOPER_ID_CERT_BASE64" | base64 --decode > developer_id.p12
keychain add-certificates \
--certificate developer_id.p12 \
--certificate-password "$DEVELOPER_ID_CERT_PASS" \
--allow-app productbuild \
--allow-app codesign \
--allow-app productsign
echo "📥 Importing Developer ID Installer certificate..."
echo "$DEVELOPER_ID_INSTALLER_CERT_BASE64" | base64 --decode > developer_id_installer.p12
keychain add-certificates \
--certificate developer_id_installer.p12 \
--certificate-password "$DEVELOPER_ID_INSTALLER_CERT_PASS" \
--allow-app productbuild \
--allow-app codesign \
--allow-app productsign
echo "✅ Keychain and certificates imported successfully."
echo "Set key partition list..."
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSPHRASE" "$KEYCHAIN_PATH"
echo "✅ Shared key partition with apple tools."
echo "keychain_path=$KEYCHAIN_PATH" >> "$GITHUB_OUTPUT"
- name: Fetch and import signing files
working-directory: nym-vpn-apple
env:
APP_STORE_CONNECT_KEY_IDENTIFIER: ${{ secrets.APP_STORE_CONNECT_API_KEY }}
APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_ISSUER_ID }}
APP_STORE_CONNECT_PRIVATE_KEY: ${{ secrets.APP_STORE_CONNECT_API_PRIVATE_KEY }}
CERTIFICATE_PRIVATE_KEY: ${{ secrets.IOS_DISTRIBUTION_PRIVATE_KEY }}
run: |
set -euo pipefail
echo "📦 Fetching iOS signing files from App Store Connect..."
app-store-connect fetch-signing-files net.nymtech.vpn \
--type IOS_APP_ADHOC \
--create \
--delete-stale-profiles \
--platform IOS
echo "📦 Downloading macOS provisioning profiles..."
app-store-connect profiles list \
--type MAC_APP_DIRECT \
--state ACTIVE \
--name "NymVPN macOS + Daemon" \
--save
app-store-connect profiles list \
--type MAC_APP_DIRECT \
--state ACTIVE \
--name "NymVPN daemon" \
--save
app-store-connect profiles list \
--type MAC_APP_DIRECT \
--state ACTIVE \
--name "NymVPN macOS Widget" \
--save
keychain add-certificates
echo "✅ Signing files successfully fetched and imported."
- name: Configure sccache
if: ${{ env.RELEASE_TYPE != 'ship' }}
run: |
set -euxo pipefail
echo "🧱 Configuring sccache (incremental disabled — incompatible)..."
which sccache || brew install sccache
export RUSTC_WRAPPER="$(which sccache)"
export SCCACHE_DIR="$HOME/.cache/sccache"
export SCCACHE_CACHE_SIZE="50G"
export SCCACHE_IDLE_TIMEOUT="0"
export CARGO_INCREMENTAL=0 # ✅ disable incremental for sccache
echo "RUSTC_WRAPPER=$RUSTC_WRAPPER" >> "$GITHUB_ENV"
echo "SCCACHE_DIR=$SCCACHE_DIR" >> "$GITHUB_ENV"
echo "SCCACHE_CACHE_SIZE=$SCCACHE_CACHE_SIZE" >> "$GITHUB_ENV"
echo "SCCACHE_IDLE_TIMEOUT=$SCCACHE_IDLE_TIMEOUT" >> "$GITHUB_ENV"
echo "CARGO_INCREMENTAL=$CARGO_INCREMENTAL" >> "$GITHUB_ENV"
mkdir -p "$SCCACHE_DIR"
- name: Update core version in app
if: ${{ env.RELEASE_TYPE != 'pr' }}
run: |
set -euo pipefail
cargo install cargo-get
# Get version from Cargo.toml workspace
LIB_VERSION=$(cargo get workspace.package.version --entry nym-vpn-core/Cargo.toml)
echo "📦 Core library version: ${LIB_VERSION}"
# Path to Swift source file
app_version_file="nym-vpn-apple/ServicesMutual/Sources/AppVersionProvider/AppVersionProvider.swift"
# Validate and update
if [[ -f "$app_version_file" ]]; then
# macOS/BSD sed syntax: -i ''
sed -i '' -E 's|(public static let libVersion = ")[^"]*(")|\1'"$LIB_VERSION"'\2|' "$app_version_file"
echo "✅ libVersion updated to ${LIB_VERSION} in ${app_version_file}."
else
echo "❌ Error: AppVersionProvider.swift file not found at ${app_version_file}"
exit 1
fi
- name: Update localizations Crowdin
if: ${{ env.RELEASE_TYPE != 'pr' }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
run: |
brew tap crowdin/crowdin
brew install crowdin@4 || brew upgrade crowdin@4 || true
# Make sure the crowdin binary is on PATH for this step
export PATH="$(brew --prefix crowdin@4)/bin:$PATH"
which crowdin || echo "crowdin not in PATH"
crowdin download translations \
--config crowdin-configs/macos-ios.yml \
--project-id "$CROWDIN_PROJECT_ID" \
--token "$CROWDIN_PERSONAL_TOKEN"
- name: Build core
working-directory: nym-vpn-apple/scripts
env:
VPNLIB_SENTRY_DSN: ${{ secrets.VPND_SENTRY_DSN }}
RUSTC_WRAPPER: ${{ env.RUSTC_WRAPPER }}
SCCACHE_DIR: ${{ env.SCCACHE_DIR }}
SCCACHE_CACHE_SIZE: ${{ env.SCCACHE_CACHE_SIZE }}
SCCACHE_IDLE_TIMEOUT: ${{ env.SCCACHE_IDLE_TIMEOUT }}
RELEASE: ${{ env.RELEASE_TYPE != 'pr' }} # macOS.mk and iOS.mk both respect RELEASE=true|false
run: |
echo "🧱 Using sccache wrapper: $RUSTC_WRAPPER"
sh BuildCore.sh
- name: Tests
if: ${{ env.RELEASE_TYPE == 'pr' }}
working-directory: nym-vpn-apple/ServicesMutual
env:
ACCOUNT_REPORT_PATH: ${{ github.workspace }}/account-report.md
run: |
set -uo pipefail
which jq >/dev/null || brew install jq
# Run tests — don't abort on failure, we still want summary + report
set +e
xcodebuild test \
-scheme ServicesMutual-Package \
-destination 'platform=macOS' \
CODE_SIGNING_ALLOWED=NO \
-only-testing:ConnectionTypesTests \
-resultBundlePath TestResults.xcresult \
| xcbeautify --renderer github-actions
TEST_STATUS=${PIPESTATUS[0]}
set -e
# Test summary → step summary
BUNDLE="TestResults.xcresult"
if [[ -d "$BUNDLE" ]]; then
JSON="$(xcrun xcresulttool get test-results summary --path "$BUNDLE" --format json 2>/dev/null)"
RESULT=$(jq -r '.result // "Unknown"' <<<"$JSON")
PASSED=$(jq -r '.passedTests // 0' <<<"$JSON")
FAILED=$(jq -r '.failedTests // 0' <<<"$JSON")
SKIPPED=$(jq -r '.skippedTests // 0' <<<"$JSON")
ICON="✅"; [[ "$RESULT" != "Passed" ]] && ICON="❌"
{
echo "## 🧪 ConnectionTypes tests"
echo ""
echo "$ICON **$RESULT** — ${PASSED} passed, ${FAILED} failed, ${SKIPPED} skipped"
} >> "$GITHUB_STEP_SUMMARY"
if [[ "$FAILED" != "0" ]]; then
{
echo ""
echo "| Test | Failure |"
echo "| --- | --- |"
jq -r '.testFailures[]? | "| \(.testName) | \((.failureText // "") | gsub("[\r\n]+"; " ")) |"' <<<"$JSON"
} >> "$GITHUB_STEP_SUMMARY"
fi
else
echo "⚠️ No test results bundle found." >> "$GITHUB_STEP_SUMMARY"
fi
# Account report → step summary
if [[ -f "$ACCOUNT_REPORT_PATH" ]]; then
cat "$ACCOUNT_REPORT_PATH" >> "$GITHUB_STEP_SUMMARY"
else
echo "account-report.md not produced" >> "$GITHUB_STEP_SUMMARY"
fi
exit "$TEST_STATUS"
- name: Upload test artifacts
if: ${{ always() && env.RELEASE_TYPE == 'pr' }}
uses: actions/upload-artifact@v7
with:
name: connectiontypes-tests
path: |
nym-vpn-apple/ServicesMutual/TestResults.xcresult
account-report.md
if-no-files-found: warn
retention-days: 1
- name: Clean up test results
if: ${{ always() && env.RELEASE_TYPE == 'pr' }}
working-directory: nym-vpn-apple/ServicesMutual
run: rm -rf TestResults.xcresult
- name: Resolve iOS build number (qa/ship only)
if: ${{ (env.RELEASE_TYPE == 'qa' || env.RELEASE_TYPE == 'ship') }}
working-directory: nym-vpn-apple
env:
APP_STORE_CONNECT_KEY_IDENTIFIER: ${{ secrets.APP_STORE_CONNECT_API_KEY }}
APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_ISSUER_ID }}
APP_STORE_CONNECT_PRIVATE_KEY: ${{ secrets.APP_STORE_CONNECT_API_PRIVATE_KEY }}
run: |
set -euo pipefail
PROJECT="$(
xcodebuild -showBuildSettings \
-workspace "NymVPN.xcworkspace" \
-scheme "NymVPN" \
-configuration "Release" 2>/dev/null |
awk '/CURRENT_PROJECT_VERSION/ { print $3; exit }'
)"
echo "🚜 Resolve build number (max across all TestFlight versions)"
LATEST="$(
app-store-connect builds list \
--app-id 6471254143 \
--platform IOS \
--silent \
--no-color \
--json 2>/dev/null \
| jq '[.[].attributes.version | tonumber] | max // 0'
)"
REQUIRED=$((LATEST + 1))
TARGET=$(( PROJECT > REQUIRED ? PROJECT : REQUIRED ))
if (( TARGET != PROJECT )); then
echo "🚜 Bump build: $PROJECT → $TARGET (TestFlight latest=$LATEST)"
fastlane mac bump_build build:"$TARGET"
else
echo "✅ Keep build: $PROJECT (TestFlight latest=$LATEST)"
fi
# Export for downstream steps (build, Zulip)
echo "IOS_BUILD_NUMBER=$TARGET" >> "$GITHUB_ENV"
echo "IOS_TESTFLIGHT_LATEST=$LATEST" >> "$GITHUB_ENV"
- name: Resolve iOS build number (PR only)
if: ${{ env.RELEASE_TYPE == 'pr' }}
working-directory: nym-vpn-apple
run: |
set -euo pipefail
IOS_BUILD_NUMBER="$(
xcodebuild -showBuildSettings \
-workspace "NymVPN.xcworkspace" \
-scheme "NymVPN" \
-configuration "Release" 2>/dev/null |
awk '/CURRENT_PROJECT_VERSION/ { print $3; exit }'
)"
echo "IOS_BUILD_NUMBER=$IOS_BUILD_NUMBER" >> "$GITHUB_ENV"
echo "🔢 PR build number (no bump): $IOS_BUILD_NUMBER"
- name: Build iOS (Release, signed)
working-directory: nym-vpn-apple
env:
APP_STORE_CONNECT_KEY_IDENTIFIER: ${{ secrets.APP_STORE_CONNECT_API_KEY }}
APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_ISSUER_ID }}
APP_STORE_CONNECT_PRIVATE_KEY: ${{ secrets.APP_STORE_CONNECT_API_PRIVATE_KEY }}
run: |
set -euo pipefail
echo "🚜 Update signing (iOS only)"
# Save project.pbxproj before use-profiles mutates it, so macOS
# Release signing settings are preserved for the archive step.
cp NymVPN.xcodeproj/project.pbxproj NymVPN.xcodeproj/project.pbxproj.pre-ios-signing
xcode-project use-profiles --archive-method "ad-hoc" --code-signing-setup-verbose-logging
echo "🚀 Building signed iOS Release with Codemagic CLI..."
xcode-project build-ipa \
--workspace "NymVPN.xcworkspace" \
--scheme "NymVPN" \
--config "Release" \
--clean \
--no-color \
--log-stream stdout \
2>&1 | xcbeautify --renderer github-actions
echo "✅ Signed archive + .ipa exported"
echo "🔢 Build used: $IOS_BUILD_NUMBER (TestFlight latest=${IOS_TESTFLIGHT_LATEST:-n/a})"
# Capture iOS version (MARKETING_VERSION) for later steps (Zulip)
IOS_VERSION=$(
xcodebuild -showBuildSettings \
-workspace "NymVPN.xcworkspace" \
-scheme "NymVPN" \
-configuration "Release" 2>/dev/null |
awk '/MARKETING_VERSION/ { print $3; exit }'
)
if [[ -z "${IOS_VERSION:-}" ]]; then
echo "❌ Could not determine iOS MARKETING_VERSION"
exit 1
fi
echo "IOS_VERSION=$IOS_VERSION" >> "$GITHUB_ENV"
echo "✅ iOS version captured: $IOS_VERSION ($IOS_BUILD_NUMBER)"
- name: Generate what to test notes for iOS
if: ${{ env.RELEASE_TYPE != 'pr' && env.BUILD_IOS == 'true' }}
working-directory: nym-vpn-apple
run: |
set -euo pipefail
DATE_TIME="$(date '+%Y-%m-%d %H:%M:%S')"
BRANCH_NAME="${GITHUB_REF_NAME}"
# Get ONLY the latest commit
LATEST_COMMIT="$(git log -1 --pretty=format:'%h, %ar, message: %s')"
# Build release notes (real newlines so TestFlight renders line breaks)
RELEASE_NOTES="$(printf 'Date: %s\nBranch: %s\nLatest commit:\n- %s' "$DATE_TIME" "$BRANCH_NAME" "$LATEST_COMMIT")"
# Build whats_new.json with proper JSON escaping
export RELEASE_NOTES
ruby -rjson -e '
notes = ENV.fetch("RELEASE_NOTES")
payload = [{ language: "en-US", locale: "en-US", whats_new: notes }]
puts JSON.pretty_generate(payload)
' > whats_new.json
echo "Generated whats_new.json:"
cat whats_new.json
- name: Upload to TestFlight
if: ${{ env.RELEASE_TYPE != 'pr' && env.BUILD_IOS == 'true' }}
working-directory: nym-vpn-apple
env:
APP_STORE_CONNECT_KEY_IDENTIFIER: ${{ secrets.APP_STORE_CONNECT_API_KEY }}
APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_ISSUER_ID }}
APP_STORE_CONNECT_PRIVATE_KEY: ${{ secrets.APP_STORE_CONNECT_API_PRIVATE_KEY }}
run: |
set -euo pipefail
echo "🚀 Uploading to TestFlight..."
app-store-connect publish \
--beta-build-localizations=@file:whats_new.json \
--beta-group='External QA' \
--beta-group='Nym Team External' \
--testflight
# --beta-group="World" \
# --expire-build-submitted-for-review
echo "✅ Upload completed successfully."
- name: Restore project signing for macOS
working-directory: nym-vpn-apple
run: |
set -euo pipefail
# Restore project.pbxproj to undo any changes made by
# xcode-project use-profiles (iOS ad-hoc signing step).
# This ensures macOS Release targets retain their original
# manual signing settings from the committed project file.
if [[ -f NymVPN.xcodeproj/project.pbxproj.pre-ios-signing ]]; then
echo "🔄 Restoring project.pbxproj from pre-iOS-signing backup..."
mv NymVPN.xcodeproj/project.pbxproj.pre-ios-signing NymVPN.xcodeproj/project.pbxproj
echo "✅ macOS signing settings restored."
else
echo "ℹ️ No backup found (iOS build may have been skipped). Using project as-is."
fi
- name: Archive macOS
id: archive-macos
working-directory: nym-vpn-apple
env:
MACOSX_DEPLOYMENT_TARGET: 14.0
DEVELOPMENT_TEAM: ${{ secrets.APPLE_TEAM_ID }}
DEVELOPER_ID_APP_IDENTITY: "Developer ID Application: Nym Technologies SA (${{ secrets.APPLE_TEAM_ID }})"
run: |
set -euo pipefail
ARCHIVE_PATH="$PWD/build/NymVPN.xcarchive"
EXPORT_PATH="$PWD/builds"
EXPORT_PLIST="$PWD/export_options.plist"
mkdir -p "$EXPORT_PATH"
# Create export options plist with explicit profile-to-bundle-ID mapping
cat > "$EXPORT_PLIST" <<PLIST
<?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>developer-id</string>
<key>teamID</key><string>${DEVELOPMENT_TEAM}</string>
<key>signingStyle</key><string>manual</string>
<key>signingCertificate</key><string>Developer ID Application</string>
<key>stripSwiftSymbols</key><true/>
<key>provisioningProfiles</key>
<dict>
<key>net.nymtech.vpn</key>
<string>NymVPN macOS + Daemon</string>
<key>net.nymtech.vpn.daemon</key>
<string>NymVPN daemon</string>
<key>net.nymtech.vpn.macOSwidget</key>
<string>NymVPN macOS Widget</string>
</dict>
</dict>
</plist>
PLIST
echo "Debug: Checking workspace and scheme..."
ls -la NymVPN.xcworkspace
ls -la NymVPN.xcodeproj/xcshareddata/xcschemes/
echo "Debug: Code signing identity: ${DEVELOPER_ID_APP_IDENTITY}"
echo "Debug: Development team: ${DEVELOPMENT_TEAM}"
echo "Available signing identities:"
security find-identity -v -p codesigning
echo "🔐 Resolving macOS provisioning profile specifiers..."
PROFILE_DIR="$HOME/Library/MobileDevice/Provisioning Profiles"
DAEMON_PROFILE_SPEC=""
WIDGET_PROFILE_SPEC=""
for profile in "$PROFILE_DIR"/*.provisionprofile "$PROFILE_DIR"/*.mobileprovision; do
[[ -f "$profile" ]] || continue
name=$(security cms -D -i "$profile" 2>/dev/null | plutil -extract Name raw -o - -- -)
case "$name" in
"NymVPN macOS + Daemon") DAEMON_PROFILE_SPEC="$name" ;;
"NymVPN macOS Widget") WIDGET_PROFILE_SPEC="$name" ;;
esac
done
if [[ -z "$DAEMON_PROFILE_SPEC" || -z "$WIDGET_PROFILE_SPEC" ]]; then
echo "❌ Could not resolve macOS profile specifiers"
echo " Daemon: '${DAEMON_PROFILE_SPEC:-NOT FOUND}'"
echo " Widget: '${WIDGET_PROFILE_SPEC:-NOT FOUND}'"
exit 1
fi
echo "✅ Daemon profile: $DAEMON_PROFILE_SPEC"
echo "✅ Widget profile: $WIDGET_PROFILE_SPEC"
echo "Archiving macOS app..."
xcodebuild archive \
-workspace "NymVPN.xcworkspace" \
-scheme "NymVPNDaemon" \
-configuration "Release" \
-archivePath "$ARCHIVE_PATH" \
-destination "generic/platform=macOS" \
CODE_SIGN_STYLE=Manual \
DEVELOPMENT_TEAM="${DEVELOPMENT_TEAM}" \
CODE_SIGN_IDENTITY="${DEVELOPER_ID_APP_IDENTITY}" \
MACOS_DAEMON_PROFILE_SPECIFIER="${DAEMON_PROFILE_SPEC}" \
MACOS_WIDGET_PROFILE_SPECIFIER="${WIDGET_PROFILE_SPEC}" \
ENABLE_HARDENED_RUNTIME=YES \
OTHER_CODE_SIGN_FLAGS="--timestamp" \
| xcbeautify --renderer github-actions
# Verify archive was created
if [[ ! -d "$ARCHIVE_PATH" ]]; then
echo "Archive not created at $ARCHIVE_PATH"
exit 1
fi
echo "Exporting macOS app..."
xcodebuild -exportArchive \
-archivePath "$ARCHIVE_PATH" \
-exportPath "$EXPORT_PATH" \
-exportOptionsPlist "$EXPORT_PLIST"
# Find the exported app
APP_PATH="$(find "$EXPORT_PATH" -maxdepth 1 -type d -name 'NymVPN*.app' -print -quit)"
if [[ -z "$APP_PATH" || ! -d "$APP_PATH" ]]; then
echo "❌ Could not find exported app in $EXPORT_PATH"
ls -la "$EXPORT_PATH"
exit 1
fi
echo "✅ App exported to: $APP_PATH"
echo "app_path=$APP_PATH" >> "$GITHUB_OUTPUT"
- name: Create PKG
if: ${{ (env.RELEASE_TYPE == 'qa' || env.RELEASE_TYPE == 'ship') && env.BUILD_MACOS == 'true' }}
id: make-pkg
working-directory: nym-vpn-apple
env:
INSTALLER_SIGNING_IDENTITY: "Developer ID Installer: Nym Technologies SA (${{ secrets.APPLE_TEAM_ID }})"
run: |
set -euo pipefail
which xmlstarlet || brew install xmlstarlet
KEYCHAIN_PATH="${{ steps.setup-keychain.outputs.keychain_path }}"
APP_PATH="${{ steps.archive-macos.outputs.app_path }}"
APP_PLIST_PATH="$APP_PATH/Contents/Info.plist"
PRODUCT_NAME="$(plutil -extract CFBundleName raw $APP_PLIST_PATH)"
PRODUCT_BUNDLE_IDENTIFIER="$(plutil -extract CFBundleIdentifier raw $APP_PLIST_PATH)"
MARKETING_VERSION="$(plutil -extract CFBundleShortVersionString raw $APP_PLIST_PATH)"
MIN_OS_VERSION="$(plutil -extract LSMinimumSystemVersion raw $APP_PLIST_PATH)"
STAGING_DIRECTORY="$PWD/build/pkg"
PKG_PATH="$STAGING_DIRECTORY/NymVPN-$MARKETING_VERSION.pkg"
STAGING_APP_DIR="$STAGING_DIRECTORY/macos"
COMPONENT_PLIST_FILE="$STAGING_DIRECTORY/component.plist"
DIST_XML_FILE="$STAGING_DIRECTORY/Distribution"
UNSIGNED_PKG_PATH="$STAGING_DIRECTORY/NymVPN-$MARKETING_VERSION-unsigned.pkg"
NESTED_PKG_PATH="$STAGING_DIRECTORY/$PRODUCT_BUNDLE_IDENTIFIER.pkg"
# Directory with pkg scripts
SCRIPTS_DIR="$PWD/Installer/Scripts"
# Directory with pkg resources (images)
RESOURCES_DIR="$PWD/Installer/Resources"
# Ensure that staging directory is empty and exists
rm -rf "$STAGING_DIRECTORY" || true
mkdir -p "$STAGING_DIRECTORY"
# Copy NymVPN.app into staging directory for the app
echo "Copying $APP_PATH to $STAGING_APP_DIR"
mkdir "$STAGING_APP_DIR"
cp -R "$APP_PATH" "$STAGING_APP_DIR"
ls -la "$STAGING_APP_DIR"
# Generate the component property list.
pkgbuild --analyze --root "$STAGING_APP_DIR" "$COMPONENT_PLIST_FILE"
# Force the installation package (.pkg) to not be relocatable.
# This ensures the package components install in /Applications
plutil -replace "0.BundleIsRelocatable" -bool NO "$COMPONENT_PLIST_FILE"
# Allow downgrades
plutil -replace "0.BundleIsVersionChecked" -bool NO "$COMPONENT_PLIST_FILE"
# Build a temporary package using the component property list.
pkgbuild --root "$STAGING_APP_DIR" \
--identifier "$PRODUCT_BUNDLE_IDENTIFIER" \
--version "$MARKETING_VERSION" \
--install-location "/Applications" \
--scripts "$SCRIPTS_DIR" \
--min-os-version "$MIN_OS_VERSION" \
--compression latest \
--component-plist "$COMPONENT_PLIST_FILE" \
"$NESTED_PKG_PATH"
# Synthesize the distribution for the temporary package.
productbuild --synthesize --package "$NESTED_PKG_PATH" "$DIST_XML_FILE"
# Customize installer:
# - enable_anywhere=false - restrict installation to system volume only, i.e no installs on external drives
# - enable_currentUserHome=false - prevent installation into home dir
# - enable_localSystem=true - enable installation into root
# Reference: https://developer.apple.com/library/archive/documentation/DeveloperTools/Reference/DistributionDefinitionRef/Chapters/Distribution_XML_Ref.html
xmlstarlet edit --inplace \
--subnode '//installer-gui-script' --type elem -n 'domains' \
--append '//installer-gui-script/domains' --type attr -n 'enable_anywhere' --value 'false' \
--append '//installer-gui-script/domains' --type attr -n 'enable_currentUserHome' --value 'false' \
--append '//installer-gui-script/domains' --type attr -n 'enable_localSystem' --value 'true' \
"$DIST_XML_FILE"
# Add element to prompt user to close the app before installation
xmlstarlet edit --inplace \
--subnode '//installer-gui-script' \
--type elem --name "pkg-ref" \
--var new_node '$prev' \
--append '$new_node' --type attr -n 'id' --value 'net.nymtech.vpn' \
--subnode '$new_node' --type elem -n 'must-close' \
--var new_node '$prev' \
--subnode '$new_node' --type elem -n 'app' \
--append '$prev' --type attr -n 'id' --value 'net.nymtech.vpn' \
"$DIST_XML_FILE"
# Add title which acts as an app name
xmlstarlet edit --inplace \
--subnode '//installer-gui-script' \
--type elem --name "title" --value "$PRODUCT_NAME" \
"$DIST_XML_FILE"
# Add background image for light mode
# xmlstarlet edit --inplace \
# --subnode '//installer-gui-script' \
# --type elem --name "background" \
# --var new_node '$prev' \
# --append '$new_node' --type attr -n 'alignment' --value 'topleft' \
# --append '$new_node' --type attr -n 'file' --value 'background-light.png' \
# --append '$new_node' --type attr -n 'mime-type' --value 'image/png' \
# --append '$new_node' --type attr -n 'scaling' --value 'proportional' \
# "$DIST_XML_FILE"
# Add background image for dark mode
# xmlstarlet edit --inplace \
# --subnode '//installer-gui-script' \
# --type elem --name "background-darkAqua" \
# --var new_node '$prev' \
# --append '$new_node' --type attr -n 'alignment' --value 'topleft' \
# --append '$new_node' --type attr -n 'file' --value 'background-dark.png' \
# --append '$new_node' --type attr -n 'mime-type' --value 'image/png' \
# --append '$new_node' --type attr -n 'scaling' --value 'proportional' \
# "$DIST_XML_FILE"
echo "✅ Distribution XML:"
cat "$DIST_XML_FILE"
# Synthesize the final package from the distribution.
productbuild --distribution "$DIST_XML_FILE" --resources "$RESOURCES_DIR" --package-path "$STAGING_DIRECTORY" "$UNSIGNED_PKG_PATH"
echo "✅ Unsigned PKG is written to: $UNSIGNED_PKG_PATH"
# Sign separately since productbuild gets stuck on keychain password prompt when signing
productsign --timestamp \
--keychain "$KEYCHAIN_PATH" \
--sign "$INSTALLER_SIGNING_IDENTITY" \
"$UNSIGNED_PKG_PATH" "$PKG_PATH"
echo "✅ Signed PKG is written to: $PKG_PATH"
echo "pkg_path=$PKG_PATH" >> "$GITHUB_OUTPUT"
- name: Write App Store Connect API key
if: ${{ (env.RELEASE_TYPE == 'qa' || env.RELEASE_TYPE == 'ship') && env.BUILD_MACOS == 'true' }}
id: asc-key
env:
ASC_PRIVATE_KEY: ${{ secrets.APP_STORE_CONNECT_API_PRIVATE_KEY }}
run: |
set -euo pipefail
KEY_DIR="$HOME/private_keys"
mkdir -p "$KEY_DIR"
KEY_PATH="$KEY_DIR/AuthKey_${{ secrets.APP_STORE_CONNECT_API_KEY }}.p8"
echo "$ASC_PRIVATE_KEY" > "$KEY_PATH"
echo "key_path=$KEY_PATH" >> "$GITHUB_OUTPUT"
echo "✅ ASC API key written to $KEY_PATH"
- name: Notarize PKG (Developer Installer ID)
if: ${{ (env.RELEASE_TYPE == 'qa' || env.RELEASE_TYPE == 'ship') && env.BUILD_MACOS == 'true' }}
id: notarize-pkg
working-directory: nym-vpn-apple
env:
ASC_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_ISSUER_ID }}
ASC_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY }}
ASC_KEY_PATH: ${{ steps.asc-key.outputs.key_path }}
run: |
set -euxo pipefail
which jq || brew install jq
PKG="${{ steps.make-pkg.outputs.pkg_path }}"
test -f "$PKG" || { echo "❌ PKG not found at $PKG"; exit 1; }
test -f "$ASC_KEY_PATH" || { echo "❌ Missing ASC key at $ASC_KEY_PATH"; exit 1; }
OUT_JSON="$PWD/build/pkg/notarytool-log.json"
echo "📤 Submitting $PKG for notarization…"
xcrun notarytool submit "$PKG" \
--key "$ASC_KEY_PATH" \
--key-id "$ASC_KEY_ID" \
--issuer "$ASC_ISSUER_ID" \
--wait \
--output-format json | tee "$OUT_JSON"
STATUS=$(jq -r .status "$OUT_JSON")
REQUEST_ID=$(jq -r .id "$OUT_JSON")
if [[ "$STATUS" != "Accepted" ]]; then
echo "❌ Notarization failed ($STATUS)"
echo "🪵 Fetching detailed log for Request ID: $REQUEST_ID"
LOG_PATH="$PWD/build/pkg/notarytool-log-detailed.json"
xcrun notarytool log "$REQUEST_ID" \
--key "$ASC_KEY_PATH" \
--key-id "$ASC_KEY_ID" \
--issuer "$ASC_ISSUER_ID" \
--output-format json | tee "$LOG_PATH"
echo "📋 Detailed log saved at: $LOG_PATH"
cat "$LOG_PATH"
exit 1
fi
echo "✅ Notarization status: Accepted"
echo "📎 Stapling PKG…"
xcrun stapler staple "$PKG"
xcrun stapler validate "$PKG"
echo "pkg_path=$PKG" >> "$GITHUB_OUTPUT"
- name: Generate artifact name
if: ${{ (env.RELEASE_TYPE == 'qa' || env.RELEASE_TYPE == 'ship') && env.BUILD_MACOS == 'true' }}
run: |
TIMESTAMP=$(date +"%Y%m%d-%H%M")
SAFE_BRANCH=$(echo "${GITHUB_REF_NAME}" | tr '/' '-')
echo "ARTIFACT_NAME=apple-${RELEASE_TYPE}-${TIMESTAMP}-${SAFE_BRANCH}" >> "$GITHUB_ENV"
- name: Rename PKG for upload
if: ${{ (env.RELEASE_TYPE == 'qa' || env.RELEASE_TYPE == 'ship') && env.BUILD_MACOS == 'true' }}
working-directory: nym-vpn-apple/build/pkg
run: |
set -euo pipefail
ORIGINAL_PKG=$(basename "${{ steps.notarize-pkg.outputs.pkg_path || steps.make-pkg.outputs.pkg_path }}")
# Strip "apple-" prefix → keep timestamp-branch only
PREFIX_PART=$(echo "${ARTIFACT_NAME}" | sed 's/^apple-//')
NEW_NAME="${PREFIX_PART}-${ORIGINAL_PKG}"
echo "📦 Renaming package:"
echo " Old: $ORIGINAL_PKG"
echo " New: $NEW_NAME"
mv "$ORIGINAL_PKG" "$NEW_NAME"
# Export relative path (relative to repository root)
RELATIVE_PATH="nym-vpn-apple/build/pkg/$NEW_NAME"
echo "renamed_pkg_relative_path=$RELATIVE_PATH" >> "$GITHUB_ENV"
echo "✅ Renamed and ready: $RELATIVE_PATH"
- name: Create ZIP archive from signed PKG (ship)
if: ${{ env.RELEASE_TYPE == 'ship' && env.BUILD_MACOS == 'true' }}
working-directory: nym-vpn-apple
run: |
set -euo pipefail
if [[ -z "${renamed_pkg_relative_path:-}" ]]; then
echo "❌ renamed_pkg_relative_path not set (did Rename PKG for upload run?)"
exit 1
fi
# Resolve signed, renamed PKG path (ship-20251119-2202-feat-sparkle-NymVPN-2.13.0.pkg)
PKG_BASENAME="$(basename "${renamed_pkg_relative_path}")"
PKG_PATH="$PWD/build/pkg/$PKG_BASENAME"
if [[ ! -f "$PKG_PATH" ]]; then
echo "❌ Signed PKG not found at $PKG_PATH"
ls -la "$PWD/build/pkg"
exit 1
fi
echo "📦 Using signed PKG: $PKG_PATH"
echo "📄 Reading MARKETING_VERSION from Xcode build settings…"
VERSION=$(
xcodebuild -showBuildSettings \
-workspace "NymVPN.xcworkspace" \
-scheme "NymVPNDaemon" \
-configuration "Release" 2>/dev/null |
awk '/MARKETING_VERSION/ { print $3; exit }'
)
if [[ -z "$VERSION" ]]; then
echo "❌ Could not determine MARKETING_VERSION from Xcode (MARKETING_VERSION)"
exit 1
fi
echo "✅ MARKETING_VERSION: $VERSION"
ARCHIVE_DIR="$PWD/build/pkg"
# 🚩 This is the **final** PKG name we want user-facing
FINAL_PKG_NAME="NymVPN-${VERSION}.pkg"
FINAL_PKG_PATH="$ARCHIVE_DIR/$FINAL_PKG_NAME"
echo "📛 Copying signed PKG to final name: $FINAL_PKG_PATH"
cp "$PKG_PATH" "$FINAL_PKG_PATH"
# 🎁 Create zip with the PKG at the zip root (no extra "pkg" folder)
pushd "$ARCHIVE_DIR" >/dev/null
NOTARISATION_ARCHIVE="NymVPN-${VERSION}.zip"
echo "🧷 Creating archive: $NOTARISATION_ARCHIVE"
# No --keepParent, and run from ARCHIVE_DIR so zip contains just NymVPN-<version>.pkg at root
ditto -c -k "$FINAL_PKG_NAME" "$NOTARISATION_ARCHIVE"
echo "📂 Archive directory contents after zip:"
ls -la
popd >/dev/null
# Export absolute zip path for the Sparkle/sign_update step
echo "zip_archive_path=$ARCHIVE_DIR/$NOTARISATION_ARCHIVE" >> "$GITHUB_ENV"
echo "FINAL_PKG_PATH=$FINAL_PKG_PATH" >> "$GITHUB_ENV"
echo "MACOS_VERSION=$VERSION" >> "$GITHUB_ENV"
- name: Generate Sparkle appcast & SHA-256 hash (sign_update)
if: ${{ env.RELEASE_TYPE == 'ship' && env.BUILD_MACOS == 'true' }}
working-directory: nym-vpn-apple/build/pkg
env:
APPLE_SPARKLE_PRIVATE_KEY: ${{ secrets.APPLE_SPARKLE_PRIVATE_KEY }}
APP_PATH: ${{ steps.archive-macos.outputs.app_path }}
ZIP_ARCHIVE_PATH: ${{ env.zip_archive_path }}
run: |
set -euo pipefail
which jq || brew install jq
if [[ -z "${renamed_pkg_relative_path:-}" ]]; then
echo "❌ renamed_pkg_relative_path not set"
exit 1
fi
INSTALLER_PKG="$(basename "${renamed_pkg_relative_path}")"
if [[ ! -f "$INSTALLER_PKG" ]]; then
echo "❌ PKG file not found in $(pwd): $INSTALLER_PKG"
ls -la
exit 1
fi
echo "📦 Using PKG: $INSTALLER_PKG"
if [[ -z "${ZIP_ARCHIVE_PATH:-}" || ! -f "$ZIP_ARCHIVE_PATH" ]]; then
echo "❌ ZIP_ARCHIVE_PATH invalid or missing: $ZIP_ARCHIVE_PATH"
ls -la
exit 1
fi
ZIP_FILE="$(basename "$ZIP_ARCHIVE_PATH")"
if [[ "$ZIP_FILE" != *.zip ]]; then
echo "❌ ZIP_ARCHIVE_PATH does not look like a .zip: $ZIP_FILE"
exit 1
fi
# Make sure the zip is in the current working directory (build/pkg)
if [[ ! -f "$ZIP_FILE" ]]; then
echo "📥 Copying zip into working dir…"
cp "$ZIP_ARCHIVE_PATH" .
fi
echo "🧷 Using ZIP for Sparkle signing: $ZIP_FILE"
if [[ -z "${APP_PATH:-}" || ! -d "$APP_PATH" ]]; then
echo "❌ APP_PATH not set or invalid: $APP_PATH"
exit 1
fi
APP_PLIST="$APP_PATH/Contents/Info.plist"
SHORT_VERSION="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$APP_PLIST")"
echo "ℹ️ CFBundleShortVersionString: $SHORT_VERSION"
# --- Download latest Sparkle distribution for sign_update ---
SPARKLE_DIR="$RUNNER_TEMP/sparkle"
mkdir -p "$SPARKLE_DIR"
echo "📥 Fetching latest Sparkle release metadata…"
curl -fsSL "https://api.github.com/repos/sparkle-project/Sparkle/releases/latest" \
-o "$SPARKLE_DIR/release.json"
ASSET_URL=$(jq -r '.assets[] | select(.name | endswith(".tar.xz")) | .browser_download_url' \
"$SPARKLE_DIR/release.json" | head -n 1)
if [[ -z "$ASSET_URL" || "$ASSET_URL" == "null" ]]; then
echo "❌ Could not find Sparkle .tar.xz asset in latest release"
jq '.' "$SPARKLE_DIR/release.json" || true
exit 1
fi
echo "⬇️ Downloading Sparkle from: $ASSET_URL"
SPARKLE_TAR="$SPARKLE_DIR/Sparkle.tar.xz"
curl -fsSL "$ASSET_URL" -o "$SPARKLE_TAR"
EXTRACT_DIR="$SPARKLE_DIR/extracted"
mkdir -p "$EXTRACT_DIR"
tar -xf "$SPARKLE_TAR" -C "$EXTRACT_DIR" --strip-components=1
SPARKLE_BIN="$EXTRACT_DIR/bin"
if [[ ! -x "$SPARKLE_BIN/sign_update" ]]; then
echo "❌ sign_update not found in Sparkle distribution"
ls -R "$EXTRACT_DIR"
exit 1
fi
if [[ -z "${APPLE_SPARKLE_PRIVATE_KEY:-}" ]]; then
echo "❌ APPLE_SPARKLE_PRIVATE_KEY secret is empty"
exit 1
fi
# 🔑 Write EdDSA private key to a file and ensure it's removed afterwards
KEY_FILE="$SPARKLE_DIR/sparkle_private_key_file"
printf '%s' "$APPLE_SPARKLE_PRIVATE_KEY" > "$KEY_FILE"
chmod 600 "$KEY_FILE"
trap "rm -f '$KEY_FILE'" EXIT
echo "📝 Signing ZIP with Sparkle EdDSA key…"
# sign_update prints something like:
# sparkle:edSignature="…" length="123456"
SPARKLE_ATTRS=$("$SPARKLE_BIN/sign_update" --ed-key-file "$KEY_FILE" "$ZIP_FILE")
if [[ -z "${SPARKLE_ATTRS:-}" ]]; then
echo "❌ Failed to obtain Sparkle signature attributes from sign_update"
exit 1
fi
echo "✅ Sparkle attributes: $SPARKLE_ATTRS"
echo "🔐 Computing SHA-256 (ZIP payload)"
shasum -a 256 "$ZIP_FILE" > sha256-hash.txt
echo "✅ sha256-hash.txt:"
cat sha256-hash.txt
DOWNLOAD_URL="https://github.com/nymtech/nym-vpn-client/releases/download/nym-vpn-macOS-v${MACOS_VERSION}/NymVPN-${MACOS_VERSION}.zip"
HOST_LINK="https://nymvpn.com"
PUB_DATE="$(LC_ALL=C date -u '+%a, %d %b %Y %H:%M:%S %z')"
echo "🧾 Creating appcast.xml…"
{
echo '<?xml version="1.0" standalone="yes"?>'
echo '<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">'
echo ' <channel>'
echo ' <title>NymVPN</title>'
echo " <link>${HOST_LINK}</link>"
echo ' <item>'
echo " <title>${SHORT_VERSION}</title>"
echo " <pubDate>${PUB_DATE}</pubDate>"
echo " <sparkle:version>${IOS_BUILD_NUMBER}</sparkle:version>"
echo " <sparkle:shortVersionString>${SHORT_VERSION}</sparkle:shortVersionString>"
echo " <sparkle:minimumSystemVersion>14.0</sparkle:minimumSystemVersion>"
echo ' <enclosure'
echo " url=\"${DOWNLOAD_URL}\""
echo ' sparkle:os="macos" sparkle:installationType="package"'
echo ' type="application/octet-stream"'
printf ' %s />\n' "${SPARKLE_ATTRS}"
echo ' </item>'
echo ' </channel>'
echo '</rss>'
} > appcast.xml
echo "✅ Generated appcast.xml:"
cat appcast.xml
PUBLISH_DIR="publish"
rm -rf "$PUBLISH_DIR"
mkdir -p "$PUBLISH_DIR"
cp "$ZIP_FILE" "$PUBLISH_DIR/"
cp "$FINAL_PKG_PATH" "$PUBLISH_DIR/"
cp appcast.xml "$PUBLISH_DIR/"
cp sha256-hash.txt "$PUBLISH_DIR/"
echo "📂 Publish folder contents:"
ls -la "$PUBLISH_DIR"
echo "publish_relative_path=nym-vpn-apple/build/pkg/$PUBLISH_DIR" >> "$GITHUB_ENV"
echo "🧩 Extracting <item> block for nym-websites…"
awk '
BEGIN { p=0 }
/<item>/{ p=1 }
p { print }
/<\/item>/{ p=0 }
' appcast.xml > appcast-item.xml
echo "✅ appcast-item.xml:"
cat appcast-item.xml
# Export for later steps
echo "APPCAST_ITEM_RELATIVE_PATH=nym-vpn-apple/build/pkg/appcast-item.xml" >> "$GITHUB_ENV"
- name: Upload artifacts (QA)
if: ${{ env.RELEASE_TYPE == 'qa' && env.BUILD_MACOS == 'true' }}
uses: actions/upload-artifact@v7
with:
name: ${{ env.ARTIFACT_NAME }}
path: ${{ env.renamed_pkg_relative_path }}
if-no-files-found: error
retention-days: 1
- name: Prepare nightly PKG asset
if: ${{ github.event_name == 'schedule' && env.RELEASE_TYPE == 'qa' && env.BUILD_MACOS == 'true' }}
working-directory: nym-vpn-apple/build/pkg
run: |
set -euo pipefail
if [[ -z "${renamed_pkg_relative_path:-}" ]]; then
echo "❌ renamed_pkg_relative_path not set"
exit 1
fi
SRC_BASENAME="$(basename "${renamed_pkg_relative_path}")"
if [[ ! -f "$SRC_BASENAME" ]]; then
echo "❌ Source PKG not found: $SRC_BASENAME"
ls -la
exit 1
fi
echo "📦 Using source PKG: $SRC_BASENAME"
cp "$SRC_BASENAME" "NymVPN-nightly.pkg"
# Export path for the release step (from repo root)
echo "NIGHTLY_PKG_PATH=nym-vpn-apple/build/pkg/NymVPN-nightly.pkg" >> "$GITHUB_ENV"
echo "✅ Nightly PKG prepared as NymVPN-nightly.pkg"
- name: Publish macOS nightly GitHub Release
if: ${{ github.event_name == 'schedule' && env.RELEASE_TYPE == 'qa' && env.BUILD_MACOS == 'true' }}
uses: softprops/action-gh-release@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: macos-nightly
name: "NymVPN macOS nightly"
prerelease: true
draft: false
files: ${{ env.NIGHTLY_PKG_PATH }}
body: |
Nightly macOS build of NymVPN.
- Branch: ${{ github.ref_name }}
- Commit: ${{ github.sha }}
- Built at: ${{ github.run_started_at }}
- name: Check if macOS release tag already exists
if: ${{ env.RELEASE_TYPE == 'ship' && env.BUILD_MACOS == 'true' }}
id: macos_tag
shell: bash
run: |
set -euo pipefail
TAG="nym-vpn-macOS-v${MACOS_VERSION}"
if git ls-remote --tags origin "refs/tags/${TAG}" | grep -q .; then
echo "exists=true" >> "$GITHUB_OUTPUT"
echo "Tag already exists: ${TAG}"
else
echo "exists=false" >> "$GITHUB_OUTPUT"
echo "Tag does not exist: ${TAG}"
fi
- name: Publish macOS GitHub Release
if: ${{ env.RELEASE_TYPE == 'ship' && env.BUILD_MACOS == 'true' && steps.macos_tag.outputs.exists != 'true' }}
uses: softprops/action-gh-release@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: nym-vpn-macos-v${{ env.MACOS_VERSION }}
name: "NymVPN macOS v${{ env.MACOS_VERSION }}"
prerelease: false
draft: false
files: |
nym-vpn-apple/build/pkg/publish/NymVPN-${{ env.MACOS_VERSION }}.zip
nym-vpn-apple/build/pkg/publish/sha256-hash.txt
body: |
Release macOS build of NymVPN.
- Version: ${{ env.MACOS_VERSION }}
- Branch: ${{ github.ref_name }}
- Commit: ${{ github.sha }}
- Built at: ${{ github.run_started_at }}
- name: Upload artifacts (Ship + appcast + hash)
if: ${{ env.RELEASE_TYPE == 'ship' && env.BUILD_MACOS == 'true' }}
uses: actions/upload-artifact@v7
with:
name: ${{ env.ARTIFACT_NAME }}
path: ${{ env.publish_relative_path }}
if-no-files-found: error
retention-days: 1
- name: Select upload source for www
if: ${{ (env.RELEASE_TYPE == 'qa' || env.RELEASE_TYPE == 'ship') && env.BUILD_MACOS == 'true' }}
run: |
set -euo pipefail
if [[ "${RELEASE_TYPE}" == "qa" ]]; then
UPLOAD_SOURCE="${renamed_pkg_relative_path}"
echo "UPLOAD_SOURCE=${UPLOAD_SOURCE}" >> "$GITHUB_ENV"
echo "🌐 QA: will upload PKG only → ${UPLOAD_SOURCE}"
else
UPLOAD_SOURCE="${publish_relative_path}"
echo "UPLOAD_SOURCE=${UPLOAD_SOURCE}" >> "$GITHUB_ENV"
echo "🌐 Ship: will upload ZIP + appcast.xml + sha256-hash.txt → ${UPLOAD_SOURCE}"
fi
- name: Upload to www
if: ${{ (env.RELEASE_TYPE == 'qa' || env.RELEASE_TYPE == 'ship') && env.BUILD_MACOS == 'true' }}
uses: easingthemes/ssh-deploy@main
with:
REMOTE_PORT: 22
ARGS: -avzr
SSH_CMD_ARGS: -o StrictHostKeyChecking=no
env:
SSH_PRIVATE_KEY: ${{ secrets.CI_WWW_SSH_PRIVATE_KEY }}
REMOTE_HOST: ${{ secrets.CI_WWW_REMOTE_HOST }}
REMOTE_USER: ${{ secrets.CI_WWW_REMOTE_USER }}
TARGET: ${{ secrets.CI_WWW_REMOTE_TARGET }}/builds/nym-vpn-client/macOS/
SOURCE: ${{ env.UPLOAD_SOURCE }}
- name: Checkout nym-websites
if: ${{ env.RELEASE_TYPE == 'ship' && env.BUILD_MACOS == 'true' }}
uses: actions/checkout@v6
with:
fetch-depth: 1
repository: nymtech/nym-websites
token: ${{ secrets.WEB_REPO_TOKEN_FOR_APPCAST }}
path: nym-websites
- name: Insert new Sparkle <item> into nym-websites appcast.xml
if: ${{ env.RELEASE_TYPE == 'ship' && env.BUILD_MACOS == 'true' }}
working-directory: nym-websites
env:
ITEM_FILE: ${{ github.workspace }}/${{ env.APPCAST_ITEM_RELATIVE_PATH }}
VERSION: ${{ env.MACOS_VERSION }}
run: |
set -euo pipefail
TARGET="websites/nym/www/public/.wellknown/macos-vpn/appcast.xml"
test -f "$ITEM_FILE" || { echo "❌ Missing ITEM_FILE: $ITEM_FILE"; exit 1; }
test -f "$TARGET" || { echo "❌ Missing TARGET: $TARGET"; exit 1; }
tmp="$(mktemp)"
{
head -n 4 "$TARGET"
cat "$ITEM_FILE"
tail -n +5 "$TARGET"
} > "$tmp"
mv "$tmp" "$TARGET"
echo "✅ Updated $TARGET (inserted after line 4)"
sed -n '1,40p' "$TARGET" || true
- name: Create PR in nym-websites
if: ${{ env.RELEASE_TYPE == 'ship' && env.BUILD_MACOS == 'true' }}
uses: peter-evans/create-pull-request@v8
with:
token: ${{ secrets.WEB_REPO_TOKEN_FOR_APPCAST }}
path: nym-websites
commit-message: Update Appcast.xml with v${{ env.MACOS_VERSION }}
title: Update Appcast.xml with v${{ env.MACOS_VERSION }}
body: |
Automated Sparkle appcast update for macOS v${{ env.MACOS_VERSION }}.
Source: nym-vpn-client build-nym-vpn-apple (ship).
branch: automation/update-macos-appcast-v${{ env.MACOS_VERSION }}
base: main
delete-branch: true
- name: Install Sentry (nightly)
if: ${{ github.event_name == 'schedule' && env.RELEASE_TYPE == 'qa' && env.BUILD_IOS == 'true' }}
run: |
brew install sentry-cli || brew upgrade sentry-cli || true
sentry-cli --version
- name: Upload iOS dSYMs to Sentry (nightly)
if: ${{ github.event_name == 'schedule' && env.RELEASE_TYPE == 'qa' && env.BUILD_IOS == 'true' }}
working-directory: nym-vpn-apple
env:
SENTRY_AUTH_TOKEN: ${{ secrets.APPLE_SENTRY_CLI_AUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.APPLE_SENTRY_CLI_ORG }}
SENTRY_PROJECT: ${{ secrets.APPLE_SENTRY_CLI_PROJECT }}
run: |
set -euo pipefail
BASE="$PWD/build/ios/xcarchive"
echo "🔎 Locating iOS xcarchive under: $BASE"
IOS_XCARCHIVE="$(find "$BASE" -maxdepth 1 -type d -name "*.xcarchive" -print -quit 2>/dev/null || true)"
if [[ -z "${IOS_XCARCHIVE:-}" || ! -d "$IOS_XCARCHIVE" ]]; then
echo "❌ No iOS .xcarchive found in $BASE"
ls -la "$BASE" || true
exit 1
fi
DSYMS_DIR="$IOS_XCARCHIVE/dSYMs"
if [[ ! -d "$DSYMS_DIR" ]]; then
echo "No debug symbols dir found at $DSYMS_DIR, skip publishing to Sentry"
exit 0
fi
if ! find "$DSYMS_DIR" -name "*.dSYM" -print -quit | grep -q .; then
echo "No .dSYM bundles found in $DSYMS_DIR, skip publishing to Sentry"
exit 0
fi
echo "📤 Uploading iOS debug files from: $DSYMS_DIR"
sentry-cli debug-files upload \
--auth-token "$SENTRY_AUTH_TOKEN" \
--org "$SENTRY_ORG" \
--project "$SENTRY_PROJECT" \
--include-sources "$DSYMS_DIR"
- name: Upload macOS dSYMs to Sentry (nightly)
if: ${{ github.event_name == 'schedule' && env.RELEASE_TYPE == 'qa' && env.BUILD_MACOS == 'true' }}
working-directory: nym-vpn-apple
env:
SENTRY_AUTH_TOKEN: ${{ secrets.APPLE_SENTRY_CLI_AUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.APPLE_SENTRY_CLI_ORG }}
SENTRY_PROJECT: ${{ secrets.APPLE_SENTRY_CLI_PROJECT }}
run: |
set -euo pipefail
DSYMS_DIR="$PWD/build/NymVPN.xcarchive/dSYMs"
if [[ ! -d "$DSYMS_DIR" ]]; then
echo "No debug symbols dir found at $DSYMS_DIR, skip publishing to Sentry"
ls -la "$PWD/build" || true
exit 0
fi
if ! find "$DSYMS_DIR" -name "*.dSYM" -print -quit | grep -q .; then
echo "No .dSYM bundles found in $DSYMS_DIR, skip publishing to Sentry"
exit 0
fi
echo "📤 Uploading macOS debug files from: $DSYMS_DIR"
sentry-cli debug-files upload \
--auth-token "$SENTRY_AUTH_TOKEN" \
--org "$SENTRY_ORG" \
--project "$SENTRY_PROJECT" \
--include-sources "$DSYMS_DIR"
- name: Compute macOS download URL (www)
if: ${{ (env.RELEASE_TYPE == 'qa' || env.RELEASE_TYPE == 'ship') && env.BUILD_MACOS == 'true' }}
env:
BASE_URL: ${{ secrets.ZULIP_BOT_APPLE_BUILD_SERVER_URL }}
run: |
set -euo pipefail
if [[ -z "${BASE_URL:-}" ]]; then
echo "❌ BASE_URL secret (WWW_MACOS_BASE_URL) is empty"
exit 1
fi
if [[ "${RELEASE_TYPE}" == "qa" ]]; then
FILE="$(basename "${renamed_pkg_relative_path}")"
URL="${BASE_URL}/nym-vpn-client/macOS/${FILE}"
else
URL="${BASE_URL}/publish/NymVPN-${MACOS_VERSION}.zip"
fi
echo "MACOS_DOWNLOAD_URL=$URL" >> "$GITHUB_ENV"
echo "✅ macOS download URL: $URL"
- name: Compose Zulip message (iOS)
if: >-
${{ (env.RELEASE_TYPE == 'qa' || env.RELEASE_TYPE == 'ship')
&& env.BUILD_IOS == 'true' }}
run: |
set -euo pipefail
BRANCH="${GITHUB_REF_NAME}"
MODE="${RELEASE_TYPE}"
{
echo "MESSAGE_IOS<<EOF"
echo "📱 iOS ${MODE} build"
echo "• Branch: \`${BRANCH}\`"
echo "• Version: \`${IOS_VERSION}\`"
echo "• Build: \`${IOS_BUILD_NUMBER}\`"
echo "EOF"
} >> "$GITHUB_ENV"
- name: Send Zulip message (iOS)
if: >-
${{ (env.RELEASE_TYPE == 'qa' || env.RELEASE_TYPE == 'ship')
&& env.BUILD_IOS == 'true' }}
uses: zulip/github-actions-zulip/send-message@v2
with:
api-key: ${{ secrets.ZULIP_BOT_APPLE_API_KEY }}
email: ${{ secrets.ZULIP_BOT_APPLE_EMAIL }}
organization-url: ${{ secrets.ZULIP_ORG_URL }}
to: ${{ secrets.ZULIP_BOT_APPLE_CHANNEL }}
type: "stream"
topic: ${{ secrets.ZULIP_BOT_APPLE_TOPIC_IOS }}
content: ${{ env.MESSAGE_IOS }}
- name: Compose Zulip message (macOS)
if: >-
${{ (env.RELEASE_TYPE == 'qa' || env.RELEASE_TYPE == 'ship')
&& env.BUILD_MACOS == 'true' }}
run: |
set -euo pipefail
BRANCH="${GITHUB_REF_NAME}"
MODE="${RELEASE_TYPE}"
if [[ -z "${MACOS_DOWNLOAD_URL:-}" ]]; then
echo "❌ MACOS_DOWNLOAD_URL is empty (Compute macOS download URL step didn't run?)"
exit 1
fi
{
echo "MESSAGE_MACOS<<EOF"
echo "🖥️ macOS ${MODE} build"
echo "• Branch: \`${BRANCH}\`"
echo "• Download: ${MACOS_DOWNLOAD_URL}"
echo "EOF"
} >> "$GITHUB_ENV"
- name: Send Zulip message (macOS)
if: >-
${{ (env.RELEASE_TYPE == 'qa' || env.RELEASE_TYPE == 'ship')
&& env.BUILD_MACOS == 'true' }}
uses: zulip/github-actions-zulip/send-message@v2
with:
api-key: ${{ secrets.ZULIP_BOT_APPLE_API_KEY }}
email: ${{ secrets.ZULIP_BOT_APPLE_EMAIL }}
organization-url: ${{ secrets.ZULIP_ORG_URL }}
to: ${{ secrets.ZULIP_BOT_APPLE_CHANNEL }}
type: "stream"
topic: ${{ secrets.ZULIP_BOT_APPLE_TOPIC_MACOS }}
content: ${{ env.MESSAGE_MACOS }}
- name: Cleanup Keychain
if: always()
run: |
set -euo pipefail
echo "🧹 Cleaning up Codemagic keychain..."
keychain delete
echo "🧹 Removing downloaded certificates..."
if [ -d ~/Library/MobileDevice/Certificates ]; then
rm -rf ~/Library/MobileDevice/Certificates/*
fi
echo "🧹 Removing downloaded provisioning profiles..."
if [ -d ~/Library/MobileDevice/Provisioning\ Profiles ]; then
rm -rf ~/Library/MobileDevice/Provisioning\ Profiles/*
fi