CI/CD ל-.NET MAUI 10: פרסום אוטומטי ל-App Store ו-Google Play עם GitHub Actions ו-Fastlane (2026)

צנרת CI/CD מלאה ל-.NET MAUI 10 עם GitHub Actions ו-Fastlane: build חתום ל-iOS ואנדרואיד, העלאה אוטומטית ל-TestFlight ול-Google Play Internal Testing, וניהול תעודות עם Fastlane match. כל הקוד ב-YAML ו-Fastfile מוכן להעתקה.

CI/CD ל-MAUI 10: פרסום אוטומטי לחנויות

עודכן: 1 בספטמבר, 2026

צנרת CI/CD ל-.NET MAUI 10 היא סדרה של שלבים אוטומטיים שלוקחים commit ל-main, בונים .ipa ו-.aab חתומים, ומעלים אותם ל-TestFlight ול-Internal Testing ב-Google Play, בלי שאף אחד יגע בזה ידנית. רוב הצוותים מדלגים על ניהול תעודות ומפתחות API בצורה אוטומטית, ואז בכל release מישהו יושב שעתיים לחדש provisioning profile ידני. בפרויקט האחרון שלי הפסקתי לספור כמה שישי בערב נשרפו ככה. המדריך הזה מראה איך בונים את הצנרת נכון מהיסוד, עם קוד עובד ל-GitHub Actions, Fastlane match ו-App Store Connect API ב-2026.

  • ב-.NET MAUI 10 מריצים dotnet publish -f net10.0-ios ו--f net10.0-android ב-GitHub Actions על runner של macos-14 ו-ubuntu-24.04 בהתאמה.
  • Fastlane match מאחסן תעודות iOS ו-provisioning profiles ב-repo פרטי מוצפן ב-Git, במקום להחזיק אותם ב-Keychain של מפתח יחיד.
  • Google Play דורש Android App Bundle (AAB) מאז אוגוסט 2021 לאפליקציות חדשות; -p:AndroidPackageFormat=aab מפעיל את זה ב-MAUI.
  • App Store Connect API key (מסוג .p8) מחליף לחלוטין את סיסמאות ה-App Store, ומשתמשים ב-APP_STORE_CONNECT_API_KEY_ID, ISSUER_ID ו-KEY_CONTENT.
  • מספר build (ApplicationVersion) חייב לעלות מונוטונית, והדרך הנקייה היא להשתמש ב-GITHUB_RUN_NUMBER כמקור אמת יחיד.
  • העלאה ל-TestFlight מ-Fastlane היא pilot upload, ולאנדרואיד supply --track internal עם service account של Google Play.

ארכיטקטורת הצנרת: מה קורה בכל שלב

לפני שכותבים שורת YAML אחת, כדאי לצייר את הצנרת מלמעלה למטה. אני מחלקת אותה תמיד לשישה שלבים ברורים, כי כשמשהו נשבר בעוד שלושה חודשים, אני רוצה לדעת בדיוק באיזה שלב להסתכל.

  1. Trigger: push ל-main או תג v*.*.* מפעיל את ה-workflow. Push ל-main הולך ל-Internal / TestFlight, ותג הולך ל-production track.
  2. Restore & test: dotnet restore, dotnet build, ואז unit tests. אם הבדיקות נופלות, שאר הצנרת לא רצה בכלל.
  3. Build iOS: job נפרד על macos-14, מייבא תעודות דרך Fastlane match ומריץ dotnet publish -f net10.0-ios.
  4. Build Android: job על ubuntu-24.04 (זול פי חמישה מ-macOS), מייצר AAB חתום.
  5. Upload: job עצמאי לכל חנות, שרץ רק אם ה-build הצליח. אף פעם לא מעלים מתוך אותו job שבונה, כי זה מונע retries שיוצרים build numbers כפולים.
  6. Notify: הודעה ל-Slack עם קישור ישיר ל-TestFlight build או ל-Play Console track.

ההפרדה הזאת בין build ל-upload היא נקודה שרוב הצוותים מדלגים עליה, ואז מגלים שכשה-upload נכשל בגלל rate limit של App Store Connect, ה-retry בונה גרסה חדשה עם build number חדש והראשונה נשארת יתומה. חבל.

איך מגדירים GitHub Actions ל-.NET MAUI 10

ה-workflow הבסיסי צריך שלושה jobs: test, build-ios, ו-build-android. שני האחרונים תלויים בראשון, וה-upload jobs תלויים בהם. הנה השלד המלא של .github/workflows/release.yml שאני משתמשת בו בפרודקשן:

name: Release

on:
  push:
    branches: [main]
    tags: ['v*.*.*']

env:
  DOTNET_VERSION: '10.0.x'
  BUILD_CONFIGURATION: Release

jobs:
  test:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: ${{ env.DOTNET_VERSION }}
      - name: Install MAUI workloads
        run: dotnet workload install maui
      - name: Restore
        run: dotnet restore MyApp.sln
      - name: Run unit tests
        run: dotnet test MyApp.sln --no-restore --configuration ${{ env.BUILD_CONFIGURATION }}

  build-ios:
    needs: test
    runs-on: macos-14
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: ${{ env.DOTNET_VERSION }}
      - name: Select Xcode 16
        run: sudo xcode-select -s /Applications/Xcode_16.app
      - name: Install MAUI workloads
        run: dotnet workload install maui-ios
      - name: Setup signing (Fastlane match)
        env:
          MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
          MATCH_GIT_URL: ${{ secrets.MATCH_GIT_URL }}
          MATCH_GIT_TOKEN: ${{ secrets.MATCH_GIT_TOKEN }}
        run: bundle exec fastlane ios sync_signing
      - name: Publish iOS
        env:
          BUILD_NUMBER: ${{ github.run_number }}
        run: |
          dotnet publish src/MyApp/MyApp.csproj \
            -f net10.0-ios \
            -c Release \
            -p:ArchiveOnBuild=true \
            -p:CodesignKey="Apple Distribution: My Company (TEAMID)" \
            -p:CodesignProvision="match AppStore com.mycompany.myapp" \
            -p:ApplicationVersion=$BUILD_NUMBER
      - uses: actions/upload-artifact@v4
        with:
          name: ios-ipa
          path: '**/bin/Release/net10.0-ios/ios-arm64/publish/*.ipa'

שים לב לשני דברים: needs: test על ה-build jobs מבטיח שלא נבזבז דקות של runner יקר על קוד ששבור, ו-bundle exec fastlane מריץ את Fastlane דרך Bundler. אף פעם לא מריצים fastlane ישירות ב-CI, כי אז אתה תלוי בגרסה שהותקנה גלובלית ב-runner, וזו משתנה בלי הודעה.

חתימת iOS ב-CI עם Fastlane match

חתימת iOS ב-CI היא הנושא הכואב ביותר בכל צנרת MAUI. הגישה המסורתית של לייצא .p12 מ-Keychain, לקודד ב-base64, ולהעלות כ-secret, עובדת עד שמישהו מחדש תעודה ואתה לא מוצא איפה שמרת את הסיסמה. Fastlane match פותרת את זה על ידי אחסון כל התעודות ו-provisioning profiles ב-repo פרטי מוצפן ב-Git, עם סיסמה סימטרית אחת.

מגדירים את זה חד-פעמית מהמכונה המקומית:

# Gemfile
source "https://rubygems.org"
gem "fastlane"

# fastlane/Matchfile
git_url("https://github.com/mycompany/ios-certificates")
storage_mode("git")
type("appstore")
app_identifier(["com.mycompany.myapp"])
username("[email protected]")

ואז מריצים פעם ראשונה מקומית: bundle exec fastlane match appstore. זה יוצר את התעודה ב-Apple Developer portal, מייצר provisioning profile, ומעלה את שניהם מוצפנים ל-repo של התעודות.

ב-CI, ה-lane של sync_signing נראה ככה:

# fastlane/Fastfile
platform :ios do
  lane :sync_signing do
    setup_ci if ENV['CI']
    match(
      type: "appstore",
      readonly: true,
      git_basic_authorization: Base64.strict_encode64(
        "#{ENV['MATCH_GIT_USER']}:#{ENV['MATCH_GIT_TOKEN']}"
      )
    )
  end
end

setup_ci יוצר Keychain זמני, ו-readonly: true מונע ממנו לחדש תעודות מ-CI (אתה רוצה לחדש רק מקומית, בכוונה). ה-provisioning profile שמותקן נקרא match AppStore com.mycompany.myapp, וזה השם המדויק שמעבירים ל--p:CodesignProvision ב-dotnet publish.

בניית AAB חתום ל-Google Play

מאז אוגוסט 2021, Google Play דורש Android App Bundle (AAB) לכל אפליקציה חדשה, ומאז אוגוסט 2023 גם לעדכונים של אפליקציות קיימות. ההבדל בין APK ל-AAB הוא ש-AAB הוא פורמט של פרסום, ו-Google מייצרים ממנו APKs מותאמים לכל מכשיר, מה שמקטין את גודל ההורדה ב-15%-35% בממוצע. פרטים מלאים ב-תיעוד הרשמי של Android App Bundle.

ב-.NET MAUI 10, מייצרים AAB חתום עם:

dotnet publish src/MyApp/MyApp.csproj \
  -f net10.0-android \
  -c Release \
  -p:AndroidPackageFormat=aab \
  -p:AndroidKeyStore=true \
  -p:AndroidSigningKeyStore=$RUNNER_TEMP/release.keystore \
  -p:AndroidSigningKeyAlias=$ANDROID_KEY_ALIAS \
  -p:AndroidSigningKeyPass=env:ANDROID_KEY_PASSWORD \
  -p:AndroidSigningStorePass=env:ANDROID_STORE_PASSWORD \
  -p:ApplicationVersion=${{ github.run_number }}

את ה-keystore עצמו מעלים כ-secret ב-base64:

base64 -i release.keystore | pbcopy  # macOS
# הדבק ל-secret בשם ANDROID_KEYSTORE_BASE64

וב-workflow מפענחים אותו לפני ה-publish:

- name: Decode keystore
  run: |
    echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > $RUNNER_TEMP/release.keystore

שים לב ל-env: prefix ב-AndroidSigningKeyPass. זה אומר ל-MSBuild לקרוא את הערך ממשתנה סביבה במקום להעביר אותו בשורת הפקודה, שמופיעה בלוגים של GitHub Actions. הבדל קריטי מבחינת אבטחה שקל לפספס (אני איבדתי שעות על זה בפעם הראשונה).

אסטרטגיית ניהול גרסאות ומספרי build

גם Apple וגם Google דורשים שמספר ה-build יעלה מונוטונית בכל העלאה, ולא ניתן להעלות שוב את אותו מספר, אפילו אם מחקת את הגרסה הקודמת. בפרויקטי MAUI יש שני שדות רלוונטיים ב-.csproj:

  • ApplicationDisplayVersion: הגרסה שהמשתמש רואה (1.4.2). ממופה ל-CFBundleShortVersionString ב-iOS ול-versionName באנדרואיד.
  • ApplicationVersion: מספר build פנימי. חייב להיות מספר שלם עולה. ממופה ל-CFBundleVersion ב-iOS ול-versionCode באנדרואיד.

הגישה הכי יציבה שמצאתי היא להשתמש ב-github.run_number כמקור אמת יחיד ל-ApplicationVersion. הוא עולה ב-1 בכל run של ה-workflow, גלובלית ל-repo, וכך אתה מובטח שלא תעלה שני builds עם אותו מספר גם אם הם רצים במקביל.

את ה-ApplicationDisplayVersion אני קוראת מתג ה-Git עצמו:

- name: Extract version from tag
  id: version
  run: |
    if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
      echo "display=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
    else
      echo "display=0.0.0-dev" >> $GITHUB_OUTPUT
    fi

- name: Publish
  run: |
    dotnet publish ... \
      -p:ApplicationDisplayVersion=${{ steps.version.outputs.display }} \
      -p:ApplicationVersion=${{ github.run_number }}

העלאה אוטומטית ל-TestFlight ול-Internal Testing

העלאה אוטומטית דורשת credentials שהחנויות סומכות עליהם. עבור Apple, זה App Store Connect API key, קובץ .p8 שמייצרים ב-App Store Connect → Users and Access → Integrations. עבור Google, זה service account של Google Cloud עם הרשאות ב-Play Console (התיעוד המלא ב-Google Play Developer API).

העלאה ל-TestFlight עם Fastlane pilot

# Fastfile
lane :beta do
  pilot(
    ipa: ENV['IPA_PATH'],
    api_key: {
      key_id: ENV['APP_STORE_CONNECT_API_KEY_ID'],
      issuer_id: ENV['APP_STORE_CONNECT_ISSUER_ID'],
      key: ENV['APP_STORE_CONNECT_API_KEY_CONTENT'],
      in_house: false
    },
    skip_waiting_for_build_processing: true,
    changelog: ENV['CHANGELOG'] || "Automated build ##{ENV['GITHUB_RUN_NUMBER']}"
  )
end

skip_waiting_for_build_processing: true חשוב. בלעדיו ה-lane תחכה עד ש-Apple יסיים לעבד את ה-build, מה שלוקח 15 עד 40 דקות ומבזבז זמן runner יקר. עדיף להגדיר webhook נפרד ב-App Store Connect שמודיע כשה-build מוכן.

העלאה ל-Google Play Internal Testing

# Fastfile
platform :android do
  lane :internal do
    upload_to_play_store(
      package_name: "com.mycompany.myapp",
      aab: ENV['AAB_PATH'],
      track: "internal",
      json_key_data: ENV['PLAY_SERVICE_ACCOUNT_JSON'],
      skip_upload_apk: true,
      skip_upload_metadata: true,
      skip_upload_images: true,
      skip_upload_screenshots: true
    )
  end
end

ה-skip_upload_* flags קריטיים. בלעדיהם Fastlane תנסה לסנכרן גם את metadata של ה-Play Store (screenshots, תיאורים, וכולי), ואם אין לך את הקבצים המקומיים היא תמחק אותם מהחנות. ראיתי את זה קורה יותר מפעם אחת (וזה לא כיף להסביר ל-PM).

ניהול secrets ואבטחת הצנרת

הצנרת מחזיקה בידיים את המפתחות לממלכה: תעודת חתימה, מפתחות API, keystore. כמה כללים שאני עוקבת אחריהם ללא יוצא מן הכלל:

  • Environments עם reviewers: הגדר GitHub Environment בשם production שדורש approval ידני. jobs שמעלים לחנויות רצים רק תחת ה-environment הזה. זה מונע פרסום מקרי של קוד לא בדוק.
  • OIDC במקום long-lived secrets: אם ה-service account של Google Cloud תומך, השתמש ב-google-github-actions/auth עם OIDC. זה מוציא את ה-JSON credentials מהתמונה לגמרי.
  • Rotation אחת לרבעון: App Store Connect API keys ו-Play service accounts צריכים להתחלף. שים תזכורת ביומן, אל תסמוך על "כשמישהו יזכר".
  • אל תדפיס secrets ללוגים: GitHub עושה masking אוטומטי לערכים של secrets, אבל לא לתוצאות של פענוח base64 שלהם. אם אתה חייב לבדוק ערך, השתמש ב-::add-mask::.

לעומק על אבטחת שרשרת ההספקה של אפליקציות ניידות, ראה את המדריך שלי על אסטרטגיית בדיקות ל-.NET MAUI 10. בסוף הצנרת של הבדיקות והצנרת של הפרסום מתחברות לתהליך שחרור אחד.

תקלות נפוצות ואיך לפתור אותן

אחרי שהקמתי לא מעט צנרות של MAUI, יש רשימה של תקלות שחוזרות. הנה החמש הכי כואבות.

"No profiles for 'com.mycompany.myapp' were found"

קורה כאשר CodesignProvision לא מתאים לשם ה-profile שהותקן. השם שאתה צריך הוא בדיוק match AppStore com.mycompany.myapp (אם השתמשת ב-match). ב-CI, הרץ ls ~/Library/MobileDevice/Provisioning\ Profiles/ אחרי sync_signing כדי לוודא שהותקן.

"aapt2 daemon startup failed"

קורה על runner Linux כשה-JDK לא מותקן ב-path הצפוי. ודא שיש לך שלב של actions/setup-java@v4 עם distribution: 'microsoft' ו-java-version: '17'. MAUI 10 דורש JDK 17.

"Version code X has already been used"

סימן שמישהו העלה ידנית build עם versionCode גבוה יותר מ-github.run_number הנוכחי. הפתרון הוא להוסיף offset: -p:ApplicationVersion=$(( ${{ github.run_number }} + 10000 )).

"Invalid provisioning profile: signed by an untrusted certificate"

ב-macOS 14 runners, ה-Keychain הזמני של setup_ci לפעמים לא סומך על תעודת ה-Apple Worldwide Developer Relations החדשה (G6). הפתרון: fastlane_action run install_wwdr_certificate לפני ה-match.

Runner runs out of disk space

macos-14 runners מגיעים עם ~14GB פנוי, ו-Xcode + workloads של MAUI תופסים בקלות 12GB. הוסף שלב cleanup:

- name: Free disk space
  run: |
    sudo rm -rf /Applications/Xcode_15*.app
    sudo rm -rf ~/Library/Developer/CoreSimulator/Caches

עבור אופטימיזציה נוספת של זמני build, כדאי לקרוא את המדריך על אופטימיזציית ביצועים ב-.NET MAUI 10. חלק מהעקרונות שם (בפרט NativeAOT ו-linker settings) משפיעים ישירות על זמן ה-CI.

שאלות נפוצות

האם GitHub Actions runners של macOS יקרים מדי ל-CI רציף?

macos-14 עולה פי 10 מ-ubuntu-24.04 ב-plans משולמים. הפתרון הוא לבנות iOS רק ב-main ותגים, לא ב-PRs. ל-PRs מספיק לבנות אנדרואיד ולהריץ בדיקות; זה מכסה 95% מהרגרסיות ושומר על העלות סבירה (בדרך כלל פחות מ-$50 לחודש לצוות של 5).

מה ההבדל בין APK ל-AAB ולמה חייבים AAB ל-Google Play?

APK הוא פורמט התקנה אחיד שכולל את כל המשאבים לכל ה-ABIs וה-densities. AAB הוא פורמט פרסום שמכיל את כל הקוד והמשאבים, אבל Google מייצרים ממנו APKs מותאמים לכל מכשיר בעת ההתקנה, כך שהמשתמש מוריד רק את מה שרלוונטי לו. Google Play דורש AAB מ-2021.

האם אפשר להשתמש ב-Azure DevOps במקום GitHub Actions?

בהחלט, הרעיונות זהים. השלבים העיקריים (test, build-ios על macOS agent, build-android, upload) מתורגמים ישירות ל-pipeline של Azure DevOps. Fastlane ומפתחות ה-API של החנויות זהים לחלוטין. ההבדל העיקרי הוא בסינטקס של YAML ובאופן ניהול ה-secrets דרך Azure Key Vault במקום GitHub Secrets.

איך מפרסמים ל-production אחרי בדיקות ב-TestFlight?

ה-lane של Fastlane נקראת deliver (במקום pilot). היא מקדמת build שעבר בדיקות פנימיות ל-App Store submission. באנדרואיד, משנים את ה-track מ-internal ל-production, או משתמשים ב-rollout עם אחוז חשיפה הדרגתי. תמיד עדיף לקדם build קיים ולא לבנות מחדש, כך אתה בטוח שמה שנבדק זה מה שמגיע למשתמשים.

מה עושים כשה-build של iOS עובד מקומית אבל נכשל ב-CI?

ב-99% מהמקרים זה אחת משלוש בעיות: גרסת Xcode שונה (בדוק עם xcode-select -p), תעודה או profile שחסרים (הרץ security find-identity -v -p codesigning), או workload של MAUI שלא הותקן. הוסף שלב אבחון ב-workflow שמדפיס את שלושת הפקודות האלה לפני ה-dotnet publish, וברוב המקרים תראה את הבעיה ישר.

Sofia Rodriguez
אודות הכותב Sofia Rodriguez

Mobile DevOps engineer focused on the unglamorous stuff: build pipelines, signing, store releases, and the tooling that keeps teams shipping.