بهروزرسانی: ۶ ژوئن ۲۰۲۶
پیادهسازی CI/CD برای .NET MAUI با GitHub Actions یعنی خودکارسازی کامل چرخهی ساخت، امضای دیجیتال و انتشار اپلیکیشن iOS و Android روی App Store Connect و Google Play، طوری که هر push روی شاخهی main در نهایت به یک باینری امضاشده و آمادهی انتشار برسد. راستش را بخواهید، در پنج سال گذشته این پایپلاین را در سه اپلیکیشن production راهاندازی کردهام و هر بار به این نتیجه رسیدهام که بزرگترین تله نه خود ساخت پروژه، بلکه مدیریت certificate و provisioning profile در runner ابری است (دقیقاً همان جایی که local buildتان سالم اجرا میشود ولی CI شکست میخورد). این راهنما، با تمرکز بر .NET 9 و workloadهای جدید maui-android/maui-ios، یک پایپلاین کامل و قابل اجرا به شما میدهد.
چرا CI/CD برای .NET MAUI متفاوت است؟
تفاوت اصلی پایپلاین MAUI با پروژههای .NET معمولی در دو نقطه است: نیاز به runner سیستمعامل macOS برای ساخت iOS و وابستگی به Xcode، و مدیریت دو حلقهی کامل امضای دیجیتال در iOS و Android. یک پروژهی Web API را میتوان روی هر runner ubuntu-latest ساخت، اما خروجی iOS بدون macOS و بدون code signing معتبر، حتی روی شبیهساز هم اجرا نخواهد شد.
نکتهی دیگری که خیلیها از قلم میاندازند، توزیع workloadهای .NET است. تا .NET 8، یک workload عمومی به نام maui داشتیم که هر دو پلتفرم را پوشش میداد. در .NET 9 و 10 این workload به maui-android و maui-ios تقسیم شده است تا حجم نصب در runner ابری به نصف برسد و زمان dotnet workload install که اغلب بیش از ۴ دقیقه طول میکشید، کاهش پیدا کند. نتیجهی عملی این تغییر، این است که در job مربوط به Android نباید workload iOS را نصب کنید و برعکس.
و در نهایت، build matrix. برخلاف یک کتابخانهی .NET که با یک build تمام میشود، در MAUI شما به دو job موازی نیاز دارید: یکی روی ubuntu-latest برای .aab و دیگری روی macos-15 برای .ipa. این تفکیک، هم زمان build را کاهش میدهد و هم هزینه را کنترل میکند، چون runner مک ده برابر runner لینوکس برای private repository هزینه دارد.
پیشنیازها و ساختار پروژه
قبل از نوشتن workflow، چند مورد را در پروژه آماده کنید. اول، فایل .csproj باید TargetFrameworkها را بهصورت صریح اعلام کند. در .NET 9 شکل توصیهشده این است:
<PropertyGroup>
<TargetFrameworks>net9.0-android;net9.0-ios</TargetFrameworks>
<OutputType>Exe</OutputType>
<RootNamespace>Acme.Mobile</RootNamespace>
<ApplicationId>com.acme.mobile</ApplicationId>
<ApplicationDisplayVersion>1.0</ApplicationDisplayVersion>
<ApplicationVersion>1</ApplicationVersion>
</PropertyGroup>
<PropertyGroup Condition="$(TargetFramework.Contains('-ios'))">
<RuntimeIdentifier>ios-arm64</RuntimeIdentifier>
<CodesignKey>Apple Distribution: Acme Inc (TEAMID)</CodesignKey>
<CodesignProvision>Acme Mobile AppStore</CodesignProvision>
</PropertyGroup>
<PropertyGroup Condition="$(TargetFramework.Contains('-android'))">
<AndroidPackageFormat>aab</AndroidPackageFormat>
<AndroidKeyStore>true</AndroidKeyStore>
</PropertyGroup>دوم، اگر از Visual Studio for Mac یا Rider برای امضا دستی استفاده میکردید، تمام مقادیر CodesignKey و AndroidSigningKeyAlias را از فایلهای .user پاک کنید. این مقادیر باید فقط از secrets تزریق شوند، نه commit شوند. شاخهبندی پیشنهادی من ساده است: شاخهی main هر push را به TestFlight و Google Play Internal Testing میفرستد و فقط tagهای v*.*.* به production track ارتقاء داده میشوند.
سوم، یک فایل global.json در ریشهی repository قرار دهید تا runner دقیقاً همان SDK محلی شما را نصب کند. در غیر این صورت، ارتقاء patch بعدی .NET ممکن است باعث شکست build شود، آن هم بدون اینکه حتی یک خط از کدتان تغییر کرده باشد. این یکی از آن خطاهایی است که چند بار در shopهای مختلف دیدهام و چون reproducible نیست، صادقانه بگویم، دیباگ آن واقعاً دردناک میشود.
ساخت و امضای Android در GitHub Actions
پایپلاین Android سادهتر از iOS است چون نیازی به macOS ندارد و فقط به یک keystore معتبر و چهار secret نیاز دارد. ابتدا keystore فعلی خود را به base64 تبدیل کنید و آن را در Settings → Secrets → Actions ذخیره کنید:
base64 -i release.keystore -o keystore.b64
# کپی محتوای keystore.b64 به secret با نام ANDROID_KEYSTORE_BASE64سپس workflow زیر را در مسیر .github/workflows/android.yml ایجاد کنید. این الگو در سه اپلیکیشن production من اجرا میشود و تمام پلههای آن عمداً بدون wrapper script نوشته شده تا بتوانید هر مرحله را جدا دیباگ کنید.
name: Android Build & Publish
on:
push:
branches: [main]
tags: ['v*.*.*']
workflow_dispatch:
jobs:
android:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- name: Setup .NET 9
uses: actions/setup-dotnet@v4
with:
dotnet-version: 9.0.x
- name: Setup Java 17
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
- name: Install MAUI Android workload
run: dotnet workload install maui-android --skip-sign-check
- name: Restore dependencies
run: dotnet restore src/Acme.Mobile/Acme.Mobile.csproj
- name: Decode keystore
env:
KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
run: |
echo "$KEYSTORE_B64" | base64 --decode > $RUNNER_TEMP/release.keystore
- name: Build signed AAB
env:
KEYSTORE_PASS: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
KEY_PASS: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: |
dotnet publish src/Acme.Mobile/Acme.Mobile.csproj \
-f net9.0-android -c Release \
-p:ApplicationVersion=${{ github.run_number }} \
-p:AndroidKeyStore=true \
-p:AndroidSigningKeyStore=$RUNNER_TEMP/release.keystore \
-p:AndroidSigningStorePass=$KEYSTORE_PASS \
-p:AndroidSigningKeyAlias=$KEY_ALIAS \
-p:AndroidSigningKeyPass=$KEY_PASS \
-o $RUNNER_TEMP/android-output
- name: Upload AAB artifact
uses: actions/upload-artifact@v4
with:
name: app-release-aab
path: ${{ runner.temp }}/android-output/*-Signed.aab
retention-days: 14برای امنیت بیشتر میتوانید keystore را در HashiCorp Vault یا Azure Key Vault نگه دارید، اما در عمل GitHub Secrets با چرخش سالانهی کلید برای 90٪ تیمها کافی است. اگر مدل تهدید شما شامل insider attack توسط developer دارای دسترسی به repository میشود، باید به سراغ environment-scoped secrets با required reviewer بروید.
ساخت و امضای iOS در GitHub Actions
پایپلاین iOS پیچیدهتر است چون باید Xcode، certificate و provisioning profile روی runner موقت macOS فراهم شوند. توصیهی من استفاده از macos-15 به جای macos-latest است. چرا؟ چون latest گاهی یکشبه به نسخهی بالاتر منتقل میشود و build شما را در ساعت ۲ بامداد میشکند (تجربهی شخصی، متأسفانه). نسخهی صریح، behavior را قابل پیشبینی میکند.
دو روش برای فراهم کردن certificate و profile وجود دارد: روش دستی با base64 کردن فایلها و وارد کردن به Keychain، یا روش fastlane match که آنها را در یک Git repo خصوصی نگه میدارد. برای تیمهای کوچک، روش دستی سادهتر است. workflow پایه به این شکل میشود:
name: iOS Build & Publish
on:
push:
branches: [main]
tags: ['v*.*.*']
jobs:
ios:
runs-on: macos-15
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
- name: Select Xcode 16.2
run: sudo xcode-select -s /Applications/Xcode_16.2.app
- name: Setup .NET 9
uses: actions/setup-dotnet@v4
with:
dotnet-version: 9.0.x
- name: Install MAUI iOS workload
run: dotnet workload install maui-ios --skip-sign-check
- name: Import signing certificate
env:
P12_BASE64: ${{ secrets.IOS_P12_BASE64 }}
P12_PASSWORD: ${{ secrets.IOS_P12_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
KEYCHAIN_PATH=$RUNNER_TEMP/build.keychain-db
CERT_PATH=$RUNNER_TEMP/dist.p12
echo "$P12_BASE64" | base64 --decode > $CERT_PATH
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security import $CERT_PATH -P "$P12_PASSWORD" \
-A -t cert -f pkcs12 -k $KEYCHAIN_PATH
security list-keychain -d user -s $KEYCHAIN_PATH
- name: Install provisioning profile
env:
PROFILE_BASE64: ${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }}
run: |
PROFILE_PATH=$HOME/Library/MobileDevice/Provisioning\ Profiles
mkdir -p "$PROFILE_PATH"
echo "$PROFILE_BASE64" | base64 --decode \
> "$PROFILE_PATH/Acme_Mobile_AppStore.mobileprovision"
- name: Build signed IPA
run: |
dotnet publish src/Acme.Mobile/Acme.Mobile.csproj \
-f net9.0-ios -c Release \
-p:ApplicationVersion=${{ github.run_number }} \
-p:ArchiveOnBuild=true \
-p:RuntimeIdentifier=ios-arm64 \
-p:CodesignKey="Apple Distribution: Acme Inc (TEAMID)" \
-p:CodesignProvision="Acme Mobile AppStore" \
-o $RUNNER_TEMP/ios-output
- name: Upload IPA artifact
uses: actions/upload-artifact@v4
with:
name: app-release-ipa
path: ${{ runner.temp }}/ios-output/*.ipa
retention-days: 14چند نکتهی غیربدیهی در این workflow وجود دارد. اول، ArchiveOnBuild=true ضروری است؛ بدون آن، خروجی یک .app بدون امضای کامل خواهد بود و TestFlight آن را رد میکند. دوم، نام provisioning profile باید دقیقاً با مقدار درون فایل .mobileprovision مطابقت داشته باشد، نه با نام فایل. برای دیدن نام واقعی از دستور security cms -D -i profile.mobileprovision استفاده کنید.
مدیریت certificate با fastlane match
اگر بیش از یک developer دارید یا certificateها به طور مرتب چرخش میکنند، fastlane match بهترین گزینه است. این ابزار، certificateها و profileها را در یک Git repository خصوصی encrypt شده با AES نگه میدارد و در هر job آنها را sync میکند. مزیت بزرگ آن این است که اگر یک developer دستگاه جدیدی به Apple Developer Portal اضافه کند، با اجرای match nuke و match appstore همهچیز خودکار بهروز میشود.
- name: Setup Ruby and fastlane
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.3'
bundler-cache: true
- name: Sync certificates with match
env:
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_AUTH }}
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.ASC_KEY_ID }}
APP_STORE_CONNECT_API_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
APP_STORE_CONNECT_API_KEY: ${{ secrets.ASC_KEY_P8 }}
run: bundle exec fastlane match appstore --readonlyتنظیمات Matchfile به این شکل است:
git_url("https://github.com/acme/ios-certs.git")
storage_mode("git")
type("appstore")
app_identifier(["com.acme.mobile"])
team_id("TEAMID")برای کسب اطلاعات بیشتر، مستندات رسمی fastlane match را مطالعه کنید که الگوهای رایج tag-based versioning و چرخش certificate را بهخوبی پوشش میدهد.
انتشار خودکار به App Store Connect
پس از تولید .ipa امضاشده، مرحلهی آخر آپلود آن به App Store Connect است. روش مدرن، استفاده از App Store Connect API با کلید JWT است. این روش امنتر از ذخیرهی Apple ID و رمز است و دسترسی را به سطح developer/admin/marketing محدود میکند. برای ایجاد کلید، به App Store Connect وارد شوید و در بخش Users and Access → Keys یک کلید App Manager بسازید.
- name: Upload to TestFlight
env:
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
ASC_KEY_P8: ${{ secrets.ASC_KEY_P8 }}
run: |
mkdir -p ~/private_keys
echo "$ASC_KEY_P8" > ~/private_keys/AuthKey_$ASC_KEY_ID.p8
xcrun altool --upload-app \
--type ios \
--file "$RUNNER_TEMP/ios-output/Acme.Mobile.ipa" \
--apiKey $ASC_KEY_ID \
--apiIssuer $ASC_ISSUER_IDاگر بخواهید کاربران external testing را خودکار اضافه کنید یا release noteها را همراه با build بفرستید، fastlane pilot کنترل بیشتری به شما میدهد. در سه پروژهای که این پایپلاین را پیاده کردهام، pilot را برای ارسال خودکار release note از فایل CHANGELOG.md به TestFlight استفاده میکنیم، چون آپلود ساده با altool این قابلیت را ندارد.
انتشار خودکار به Google Play
برای Google Play، یک service account از Google Cloud Console بسازید، نقش Service Account User به آن بدهید و سپس از Google Play Console آن را به برنامهی خود متصل کنید. فایل JSON کلید را base64 کنید و در secretها ذخیره نمایید. سپس fastlane supply مرحلهی آپلود را انجام میدهد:
- name: Setup Ruby and fastlane
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.3'
bundler-cache: true
- name: Decode Play service account
env:
PLAY_JSON_B64: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
run: echo "$PLAY_JSON_B64" | base64 --decode > $RUNNER_TEMP/play.json
- name: Upload to Internal Testing
run: |
bundle exec fastlane supply \
--aab artifacts/app-release-aab/*-Signed.aab \
--track internal \
--json_key $RUNNER_TEMP/play.json \
--package_name com.acme.mobileترتیب طبیعی trackها در Google Play این است: internal → closed (alpha/beta) → production. توصیهام این است که هر push روی main به internal برود و فقط tagهای v*.*.* به production ارتقاء داده شوند. این مدل سادهترین راه برای پیشگیری از انتشار اشتباه است.
نسخهگذاری خودکار و مدیریت Build Number
App Store Connect و Google Play هیچکدام اجازه نمیدهند build با شمارهی تکراری آپلود شود. سادهترین راه استفاده از ${{ github.run_number }} بهعنوان ApplicationVersion است که در هر اجرا یک شمارهی یکتای صعودی میسازد. اما اگر میخواهید شمارهی نسخهی نمایشی (1.2.3) را هم خودکار بسازید، GitVersion ابزار بالغتری است.
| قابلیت | github.run_number | GitVersion | اسکریپت دستی |
|---|---|---|---|
| پیچیدگی راهاندازی | هیچ | متوسط | کم |
| SemVer واقعی | خیر | بله | بستگی دارد |
| پشتیبانی از pre-release | خیر | بله | دستی |
| یکتایی build number | بله | بله | نیاز به دقت |
| مناسب برای تیم کوچک | عالی | اضافی | قابل قبول |
| مناسب برای enterprise | محدود | عالی | دردسرساز |
برای اکثر تیمها، الگوی سادهی زیر کافی است: نسخهی نمایشی از tag گرفته میشود و build number از run_number. این الگو در پیادهسازی بهینهسازی عملکرد .NET MAUI و NativeAOT هم با ما همراه شد و در سه release چرخهی production کاملاً پایدار بوده است.
- name: Compute version
id: version
run: |
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
VERSION="${GITHUB_REF#refs/tags/v}"
else
VERSION="0.0.${GITHUB_RUN_NUMBER}"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "build=$GITHUB_RUN_NUMBER" >> $GITHUB_OUTPUTامنیت Secretها و مدیریت کلیدها
مدیریت secret در CI/CD مهمترین بخش امنیتی پایپلاین است. یک افشای سادهی keystore کافی است تا مهاجم بتواند نسخهی جعلی اپلیکیشن شما را با امضای معتبر منتشر کند. پنج قانون که در سه پروژهی production رعایت کردهام:
- هرگز secret را در
echo یا printenv خروجی ندهید. GitHub بهطور خودکار آن را mask میکند، اما base64 شدهی آن mask نمیشود. - از
environments با required reviewer برای production track استفاده کنید تا انتشار نهایی نیاز به تأیید دستی داشته باشد. - کلیدهای App Store Connect را هر شش ماه چرخش دهید. این کار با کلیک یک دکمه در پورتال انجام میشود و هزینهی زمانی واقعی ندارد.
- keystore Android را در یک محل offline دوم backup کنید. اگر این فایل را گم کنید، نمیتوانید update منتشر کنید و باید برنامه را با package name جدید از صفر بسازید.
- دسترسی به repository
match را به حداقل ممکن محدود کنید. این repository به اندازهی خود certificateها حساس است.
مستندات رسمی Microsoft برای انتشار اپلیکیشن .NET MAUI چکلیست مفصلتری از تنظیمات production در فایل .csproj ارائه میدهد که قبل از فعال کردن پایپلاین حتماً مرور کنید.
عیبیابی خطاهای رایج CI
چند خطای رایج که در سالهای اخیر دیدهام و راهحل عملی هرکدام:
خطای «No installed provisioning profiles match»
این خطا معمولاً به دلیل عدم تطابق bundle identifier بین فایل .mobileprovision و ApplicationId در .csproj است. با security cms -D -i profile.mobileprovision محتوای پروفایل را چاپ کنید و مقدار application-identifier را با ApplicationId مقایسه نمایید.
خطای «keychain locked» هنگام امضای iOS
این مشکل زمانی رخ میدهد که security set-keychain-settings بدون -lut اجرا شود یا keychain جدید در لیست پیشفرض قرار نگیرد. دستور security list-keychain -d user -s $KEYCHAIN_PATH login.keychain-db هر دو را شامل کنید تا keychain اصلی فعال بماند.
build طولانی Android در runner ابری
پیشفرض MAUI، EnableLLVM=true روی Release را فعال میکند که زمان build را تا 60٪ افزایش میدهد. اگر اپلیکیشن شما حساس به اندازه نیست، EnableLLVM=false را تنها در CI ست کنید. برای جزئیات بیشتر راهنمای بهینهسازی عملکرد .NET MAUI را ببینید.
تستهای UI روی CI شکست میخورند
تستهای UI به شبیهساز اختصاصی و راهاندازی Appium نیاز دارند. الگوی کاملی از این پایپلاین در راهنمای تستنویسی در .NET MAUI با Appium ارائه شده است که توصیه میکنم بهصورت یک workflow جداگانه آن را اجرا کنید، نه در همان workflow build & publish.
توکن iOS API expire میشود
کلید JWT App Store Connect عمر کوتاهی دارد و باید در هر اجرا تازه ساخته شود. اگر توکن طولانیمدت ذخیره کرده باشید، در runner خطای Authentication credentials are missing دریافت میکنید. همیشه .p8 خام را به altool یا fastlane بدهید و اجازه دهید آنها توکن لحظهای بسازند. مرجع رسمی runner imageهای GitHub Actions فهرست نسخههای Xcode موجود را نگه میدارد که هنگام بهروزرسانی workflow مفید است.
اگر بخش امنیتی پایپلاین شما شامل token refresh و SecureStorage روی دستگاه میشود، الگوی JWT را که در راهنمای احراز هویت JWT در .NET MAUI توضیح دادهام، بهخوبی با همین پایپلاین CI کار میکند و نیاز به تنظیمات اضافه ندارد.
پرسشهای متداول
آیا برای ساخت iOS در GitHub Actions حتماً به macOS runner نیاز است؟
بله. خروجی iOS بدون Xcode و SDK اپل قابل تولید نیست و این ابزارها تنها روی macOS اجرا میشوند. تنها استثنا، استفاده از سرویسهای ابری مانند MacStadium است که در پشت صحنه باز هم یک macOS واقعی فراهم میکنند. هزینهی runner macos-15 ده برابر ubuntu-latest در private repository است، پس آن را تنها برای jobهای مرتبط با iOS اجرا کنید.
چه نسخهای از Xcode با .NET MAUI 9 سازگار است؟
.NET 9 نیازمند Xcode 16 یا بالاتر است و workload maui-ios به طور رسمی Xcode 16.2 را پشتیبانی میکند. روی runner macos-15 این نسخه بهصورت پیشفرض موجود است، اما همیشه با sudo xcode-select -s نسخه را بهصورت صریح انتخاب کنید تا build روی بهروزرسانی runner image قفل نشود.
چگونه میتوان APK و AAB را همزمان در یک workflow ساخت؟
برای انتشار به Google Play فقط .aab نیاز است. اگر برای توزیع جانبی به .apk نیاز دارید، یک target جداگانه با -p:AndroidPackageFormat=apk اجرا کنید. توصیهی من این است که این هدف را در workflow_dispatch نگه دارید نه در هر push، چون به ندرت لازم میشود و زمان build را افزایش میدهد.
آیا میتوان CI/CD .NET MAUI را روی Azure DevOps یا GitLab هم پیاده کرد؟
بله، منطق پایپلاین یکسان است؛ فقط syntax YAML و نام مرحلهها تفاوت دارد. Azure Pipelines بهطور بومی macOS agent با Xcode از قبل نصبشده فراهم میکند و در پروژههای enterprise اغلب راحتتر است. GitLab نیاز به self-hosted macOS runner دارد مگر اینکه از سرویس Premium آن استفاده کنید.
چطور رمز keystore را در CI بهصورت امن نگه دارم؟
keystore را به base64 تبدیل کنید و در GitHub Secrets با نام ANDROID_KEYSTORE_BASE64 ذخیره کنید. رمز keystore و key alias هم بهصورت secret جداگانه نگهداری شوند. در workflow، در $RUNNER_TEMP آن را decode کنید تا پس از پایان job بهطور خودکار حذف شود. هرگز keystore را در repository commit نکنید، حتی در شاخهی خصوصی.
آیا میتوان بدون fastlane به App Store منتشر کرد؟
بله. دستور xcrun altool --upload-app بهصورت بومی روی macOS موجود است و با کلید App Store Connect API کار میکند. fastlane لایهای از راحتی برای مدیریت release note، اضافه کردن tester و چرخش certificate اضافه میکند، اما ضروری نیست. برای تیمهای کوچک با یک اپلیکیشن، altool کافی است.




