پیاده‌سازی CI/CD برای .NET MAUI با GitHub Actions در ۲۰۲۶: ساخت، امضا و انتشار خودکار به App Store و Google Play

راهنمای عملی CI/CD برای .NET MAUI با GitHub Actions در ۲۰۲۶: ساخت Android و iOS، امضای دیجیتال با fastlane match، انتشار به TestFlight و Google Play، نسخه‌گذاری خودکار و عیب‌یابی خطاهای رایج.

CI/CD برای .NET MAUI با GitHub Actions ۲۰۲۶

به‌روزرسانی: ۶ ژوئن ۲۰۲۶

پیاده‌سازی 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‎، یک پایپ‌لاین کامل و قابل اجرا به شما می‌دهد.

  • برای ساخت iOS در GitHub Actions حتماً از runner ‎macos-14 یا ‎macos-15‎ استفاده کنید؛ runner‌های قدیمی‌تر Xcode سازگار با MAUI 9 ندارند.
  • workload صحیح در ‎.NET 9‎ ترکیب ‎maui-android maui-ios‎ است و workload عمومی ‎maui‎ از نسخه‌ی 9 منسوخ شده است.
  • برای امضای iOS، الگوی fastlane match با Git private repo پایدارترین روش است؛ Apple Developer Portal API نیز از 2025 رسماً برای ثبت device پشتیبانی می‌شود.
  • امضای Android به یک keystore base64 شده در GitHub Secrets و خروجی ‎.aab‎ نیاز دارد؛ Google Play از سال 2021 بسته‌ی ‎.apk‎ را برای انتشار نمی‌پذیرد.
  • نسخه‌گذاری خودکار با ‎GitVersion‎ یا یک اسکریپت ساده بر اساس ‎GITHUB_RUN_NUMBER‎ از بسیاری از build conflict‌ها در App Store Connect جلوگیری می‌کند.
  • هزینه‌ی runner ‎macos‎ ده برابر runner ‎ubuntu‎ است؛ ساخت Android را روی Linux و ساخت iOS را تنها در شاخه‌ی release اجرا کنید.

چرا 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_numberGitVersionاسکریپت دستی
پیچیدگی راه‌اندازیهیچمتوسطکم
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 رعایت کرده‌ام:

  1. هرگز secret را در ‎echo‎ یا ‎printenv‎ خروجی ندهید. GitHub به‌طور خودکار آن را mask می‌کند، اما base64 شده‌ی آن mask نمی‌شود.
  2. از ‎environments‎ با required reviewer برای ‎production track‎ استفاده کنید تا انتشار نهایی نیاز به تأیید دستی داشته باشد.
  3. کلیدهای App Store Connect را هر شش ماه چرخش دهید. این کار با کلیک یک دکمه در پورتال انجام می‌شود و هزینه‌ی زمانی واقعی ندارد.
  4. keystore Android را در یک محل offline دوم backup کنید. اگر این فایل را گم کنید، نمی‌توانید update منتشر کنید و باید برنامه را با package name جدید از صفر بسازید.
  5. دسترسی به 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 کافی است.

Marcus Chen
درباره نویسنده Marcus Chen

Senior mobile architect with a decade of cross-platform experience. Spent the last five years going deep on .NET MAUI in production.