CI/CD Cho .NET MAUI Với GitHub Actions: Build, Sign Và Deploy 2026

Pipeline CI/CD .NET MAUI production-tested 18 tháng: workflow YAML build Android AAB đã ký, iOS IPA trên macos-14, deploy Google Play/TestFlight qua fastlane, versioning tự động và fix lỗi thường gặp.

CI/CD .NET MAUI: GitHub Actions 2026

Cập nhật: 18 Tháng 8, 2026

Thiết lập CI/CD cho .NET MAUI với GitHub Actions cần ba workflow riêng: một job Ubuntu để build Android AAB đã ký, một job macos-14 để build iOS IPA với chứng chỉ Apple Developer, và một job deploy dùng fastlane để đẩy artifact lên Google Play internal track và TestFlight. Trong bài này tôi chia sẻ pipeline mà team tôi đã chạy production suốt 18 tháng, gồm cả cách quản lý keystore, provisioning profile, versioning tự động và những cái bẫy chúng tôi từng vấp phải.

  • iOS build bắt buộc chạy trên macos-14 hoặc mới hơn; Android có thể build trên ubuntu-24.04 với dung lượng disk lớn hơn.
  • Lưu keystore Android và .p12/provisioning profile iOS dưới dạng GitHub Secrets đã encode base64, không commit vào repo.
  • Dùng dotnet workload restore trước khi build để cài đúng workload MAUI cho từng target framework (net9.0-android, net9.0-ios).
  • Auto-increment ApplicationDisplayVersionApplicationVersion từ số github.run_number để tránh xung đột build number khi upload store.
  • fastlane supplypilot là cách bền vững nhất để deploy lên Google Play và TestFlight từ pipeline không tương tác.
  • Cache ~/.nuget/packages~/Library/Developer/Xcode/DerivedData giảm thời gian build từ 22 phút xuống còn 8 phút với dự án MAUI cỡ vừa.

Vì sao CI/CD cho .NET MAUI cần cách tiếp cận riêng

Trong ba năm dẫn team chuyển từ Xamarin.Forms sang .NET MAUI, chúng tôi thử qua Azure Pipelines, App Center Build (giờ đã ngừng nhận dự án mới), và cuối cùng dừng lại ở GitHub Actions vì lý do đơn giản: repo, secrets, và release notes đều ở một chỗ. Nhưng khác với ứng dụng web ASP.NET Core mà bạn chỉ cần dotnet publish rồi push image, MAUI đòi hỏi ba runner khác nhau: Ubuntu cho Android, macOS cho iOS/Mac Catalyst, và Windows nếu bạn còn ship WinUI 3. Mỗi runner cần workload riêng, SDK riêng, và trong trường hợp iOS còn cần chứng chỉ ký code hợp lệ.

Điểm đau chính nằm ở khâu ký (code signing). Thú thật, một team backend thường coi ký code là chuyện của "designer đăng nhập App Store Connect rồi upload tay", nhưng cách đó không scale khi bạn ship hai tuần một lần với đội 15 người. Tự động hoá được toàn bộ chuỗi build, sign, upload TestFlight/Google Play chính là ranh giới giữa một team ship được và một team mắc kẹt ở "chờ Mac của trưởng nhóm rảnh". Bài này tập trung vào cách xây pipeline đó một lần cho xong.

Kiến trúc pipeline tổng thể

Pipeline chúng tôi dùng chia thành bốn workflow riêng biệt, mỗi workflow trigger theo ngữ cảnh khác nhau, giúp giảm chi phí runner đáng kể vì runner macOS đắt gấp 10 lần Ubuntu.

WorkflowTriggerRunnerMục tiêu
ci.ymlMọi PRubuntu-24.04Build shared code, chạy unit test, static analysis
build-android.ymlPush nhánh main hoặc tagubuntu-24.04Build và ký AAB, upload artifact
build-ios.ymlPush nhánh main hoặc tagmacos-14Build IPA đã ký với distribution profile
deploy.ymlManual dispatch hoặc tag v*.*.*ubuntu-24.04 + macos-14Upload AAB lên Play, IPA lên TestFlight

Tách PR check ra khỏi build store giúp thời gian phản hồi PR còn dưới 4 phút, dev không phải chờ iOS build 14 phút mới biết mình quên format code. Nếu bạn cũng đang xây bộ test song song với pipeline, tham khảo thêm chiến lược kiểm thử .NET MAUI toàn diện để hiểu tầng test nào nên chạy ở tầng nào của pipeline.

Workflow build và sign Android AAB

Đây là workflow tôi bắt đầu mọi dự án MAUI mới. Ba secret cần chuẩn bị trước trong Settings → Secrets and variables → Actions: ANDROID_KEYSTORE_BASE64 (file .jks chạy base64), ANDROID_KEYSTORE_PASSWORD, và ANDROID_KEY_ALIAS.

name: build-android

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

jobs:
  build:
    runs-on: ubuntu-24.04
    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: Cache NuGet packages
        uses: actions/cache@v4
        with:
          path: ~/.nuget/packages
          key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }}

      - name: Install MAUI workload
        run: dotnet workload install maui-android

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

      - name: Publish signed AAB
        run: |
          dotnet publish src/MyApp/MyApp.csproj \
            -f net9.0-android \
            -c Release \
            -p:AndroidPackageFormat=aab \
            -p:AndroidKeyStore=true \
            -p:AndroidSigningKeyStore=$RUNNER_TEMP/release.jks \
            -p:AndroidSigningKeyAlias=${{ secrets.ANDROID_KEY_ALIAS }} \
            -p:AndroidSigningKeyPass=${{ secrets.ANDROID_KEYSTORE_PASSWORD }} \
            -p:AndroidSigningStorePass=${{ secrets.ANDROID_KEYSTORE_PASSWORD }} \
            -p:ApplicationVersion=${{ github.run_number }}

      - name: Upload AAB artifact
        uses: actions/upload-artifact@v4
        with:
          name: android-release
          path: '**/bin/Release/net9.0-android/**/*Signed.aab'
          retention-days: 14

Ba điểm dễ vấp: (1) maui-android workload đủ cho Android build, không cần cài full maui; (2) ApplicationVersion phải là số nguyên tăng dần, nếu không Google Play sẽ từ chối với lỗi versionCode must be higher; (3) đường dẫn *Signed.aab phụ thuộc tên project, kiểm tra bằng find . -name "*.aab" trong bước debug đầu tiên. Cá nhân tôi từng mất nửa buổi chiều chỉ vì tên project có dấu chấm khiến glob pattern không khớp.

Workflow build iOS IPA trên macOS runner

iOS phức tạp hơn vì bạn cần import cả chứng chỉ distribution (.p12) và provisioning profile (.mobileprovision) vào keychain của runner. Tôi khuyên dùng action apple-actions/import-codesign-certsapple-actions/download-provisioning-profiles. Cả hai được Apple maintain và xử lý tốt việc dọn keychain sau job.

name: build-ios

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

jobs:
  build:
    runs-on: macos-14
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: 9.0.x

      - name: Select Xcode 16.2
        run: sudo xcode-select -s /Applications/Xcode_16.2.app

      - name: Install MAUI iOS workload
        run: dotnet workload install maui-ios

      - name: Import Apple certificate
        uses: apple-actions/import-codesign-certs@v3
        with:
          p12-file-base64: ${{ secrets.APPLE_CERT_P12_BASE64 }}
          p12-password: ${{ secrets.APPLE_CERT_PASSWORD }}

      - name: Download provisioning profile
        uses: apple-actions/download-provisioning-profiles@v3
        with:
          bundle-id: com.company.myapp
          issuer-id: ${{ secrets.APPSTORE_ISSUER_ID }}
          api-key-id: ${{ secrets.APPSTORE_KEY_ID }}
          api-private-key: ${{ secrets.APPSTORE_PRIVATE_KEY }}

      - name: Publish signed IPA
        run: |
          dotnet publish src/MyApp/MyApp.csproj \
            -f net9.0-ios \
            -c Release \
            -p:ArchiveOnBuild=true \
            -p:RuntimeIdentifier=ios-arm64 \
            -p:CodesignKey="Apple Distribution: My Company (TEAMID)" \
            -p:CodesignProvision="MyApp AppStore" \
            -p:ApplicationVersion=${{ github.run_number }} \
            -p:ApplicationDisplayVersion=1.4.${{ github.run_number }}

      - uses: actions/upload-artifact@v4
        with:
          name: ios-release
          path: '**/bin/Release/net9.0-ios/ios-arm64/publish/*.ipa'

Ba secret cần cho App Store Connect API (APPSTORE_ISSUER_ID, APPSTORE_KEY_ID, APPSTORE_PRIVATE_KEY) lấy tại Users and Access → Keys → App Store Connect API. Key này thay thế hoàn toàn cho username/password Apple ID và không bị vướng two-factor authentication trong pipeline. Xem thêm hướng dẫn tạo key ở tài liệu App Store Connect API.

Deploy tự động lên Google Play

Google Play Publishing API yêu cầu service account JSON. Tạo tại Google Cloud Console, cấp quyền Release manager trong Play Console, rồi lưu file JSON dưới dạng secret PLAY_SERVICE_ACCOUNT_JSON. Với dự án MAUI, tôi dùng fastlane supply vì nó hỗ trợ tốt AAB, staged rollout, và release notes đa ngôn ngữ.

- name: Setup fastlane
  run: gem install fastlane -N

- name: Download AAB artifact
  uses: actions/download-artifact@v4
  with:
    name: android-release
    path: ./artifacts

- name: Write service account JSON
  run: echo '${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}' > $RUNNER_TEMP/play-sa.json

- name: Deploy to Play internal track
  run: |
    fastlane supply \
      --aab ./artifacts/*Signed.aab \
      --package_name com.company.myapp \
      --track internal \
      --json_key $RUNNER_TEMP/play-sa.json \
      --release_status draft \
      --skip_upload_metadata true \
      --skip_upload_images true

--release_status draft quan trọng. Chúng tôi luôn deploy dưới dạng draft, để QA lead review bản build trong Play Console trước khi bấm rollout thủ công. Tự động chuyển sang completed nghe hấp dẫn, nhưng đã ba lần cứu chúng tôi khỏi phát hành nhầm bản dính bug regression. Chi tiết đầy đủ về tham số có tại tài liệu chính thức fastlane supply.

Deploy TestFlight bằng fastlane pilot

Trên phía iOS, fastlane pilot gọi App Store Connect API để upload IPA và mời tester. Không cần Apple ID, không cần MFA, chỉ cần cùng bộ ba key APPSTORE_ISSUER_ID, APPSTORE_KEY_ID, APPSTORE_PRIVATE_KEY đã tạo ở trên.

- name: Write App Store Connect API key
  run: |
    mkdir -p ~/.appstoreconnect/private_keys
    echo '${{ secrets.APPSTORE_PRIVATE_KEY }}' > \
      ~/.appstoreconnect/private_keys/AuthKey_${{ secrets.APPSTORE_KEY_ID }}.p8

- name: Upload to TestFlight
  run: |
    fastlane pilot upload \
      --ipa ./artifacts/MyApp.ipa \
      --api_key_path <(echo '{
        "key_id":"${{ secrets.APPSTORE_KEY_ID }}",
        "issuer_id":"${{ secrets.APPSTORE_ISSUER_ID }}",
        "key":"${{ secrets.APPSTORE_PRIVATE_KEY }}",
        "in_house":false
      }') \
      --skip_waiting_for_build_processing true \
      --changelog "Automated build ${{ github.run_number }}"

Tham số --skip_waiting_for_build_processing true tiết kiệm khoảng 10-15 phút runner cho mỗi lần upload. Apple sẽ tự gửi email khi build sẵn sàng cho tester. Nếu bạn cần gửi build cho external testing group tự động, thay bằng --distribute_external true --groups "External Beta".

Chiến lược versioning và build number

Google Play và App Store đều bắt buộc build number tăng đơn điệu (monotonically increasing). Thực lòng mà nói, đây là nơi tôi thấy các team làm sai nhiều nhất: hardcode ApplicationVersion=1 trong .csproj rồi ngạc nhiên vì upload lần thứ hai bị từ chối. Cách chúng tôi làm:

  • ApplicationVersion (versionCode/CFBundleVersion): dùng github.run_number. GitHub tự tăng số này mỗi lần workflow chạy và không reset khi rebase.
  • ApplicationDisplayVersion (versionName/CFBundleShortVersionString): format 1.4.${{ github.run_number }} hoặc parse từ git tag v1.4.0.
  • Không set version cứng trong .csproj. Xoá dòng <ApplicationVersion><ApplicationDisplayVersion> để tránh xung đột giữa file và command line.

Nếu bạn ship nhiều app từ cùng repo (monorepo), github.run_number sẽ chung cho tất cả, dẫn đến nhảy số bất thường. Trong trường hợp đó, tôi tạo tag riêng cho mỗi app (myapp-v1.4.0) và parse số commit từ tag về HEAD làm build number. Cách này bền vững và không phụ thuộc vào infra CI cụ thể.

Tối ưu thời gian build và chi phí runner

Runner macOS trên GitHub-hosted plan tính giá gấp 10 lần Ubuntu (10 phút macOS = 100 phút Ubuntu quota). Với team ship hai tuần một lần, tối ưu thời gian iOS build là quan trọng nhất. Bốn cải tiến tôi khuyên áp dụng:

  1. Cache NuGet: actions/cache@v4 với key theo hash packages.lock.json. Giảm 40-90 giây mỗi build.
  2. Cache Xcode DerivedData: path ~/Library/Developer/Xcode/DerivedData. Đặc biệt hiệu quả cho binding project Objective-C. Giảm 2-3 phút.
  3. Chỉ cài workload cần thiết: maui-ios thay vì full maui. Cài full workload mất khoảng 4 phút, workload đơn lẻ chỉ 45 giây.
  4. Song song hoá Android và iOS: không để hai workflow phụ thuộc lẫn nhau nếu không cần. Runner khác nhau, chạy song song, tổng thời gian tường là max chứ không phải sum.

Với dự án MAUI cỡ vừa (~180k dòng code shared, 20 màn hình), pipeline đầy đủ của chúng tôi chạy 8 phút, bao gồm build Android + iOS + deploy cả hai store. Trước tối ưu con số này là 22 phút. Tương tự cách chúng tôi tiếp cận tối ưu hiệu suất khởi động .NET MAUI, nguyên tắc là đo trước khi tối ưu: thêm time vào từng step lớn rồi nhắm vào step tốn thời gian nhất.

Lỗi thường gặp trong pipeline .NET MAUI

Bốn lỗi tôi debug đi debug lại cho các team khác nhau:

1. "No installed provisioning profiles match the specified provisioning profile"

Nguyên nhân thường không phải profile sai mà là CodesignKey không khớp exactly với tên chứng chỉ trong keychain. Chạy security find-identity -v -p codesigning trên runner (thêm step debug tạm) để lấy tên chính xác kèm dấu ngoặc kép. Tôi hit đúng bug này khi ship bản 3.2 và loay hoay mất một buổi tối mới thấy chữ hoa/thường lệch một ký tự.

2. "The following NuGet packages have vulnerabilities" fail build

Từ .NET 9, dotnet CLI fail build khi phát hiện package vulnerability high/critical. Nếu bạn không thể update ngay, dùng -p:NuGetAuditMode=direct để chỉ audit direct dependency, hoặc -p:NuGetAudit=false tạm thời (không khuyến nghị lâu dài).

3. Android build fail với "Java heap space"

MAUI R8/D8 tốn nhiều RAM khi shrink code. Thêm vào .csproj: <AndroidDexToolExtraArgs>-JXmx4g</AndroidDexToolExtraArgs><JavaMaximumHeapSize>4g</JavaMaximumHeapSize>. Ubuntu runner mặc định có 7 GB RAM nên 4g heap là an toàn.

4. iOS IPA build thành công nhưng TestFlight từ chối "Invalid Signature"

Gần như luôn là do entitlements.plist khai báo capability (Push Notifications, Sign in with Apple) mà provisioning profile chưa bật. Vào Apple Developer portal → Certificates, Identifiers & Profiles → App IDs, đảm bảo mọi capability trong Entitlements.plist đều được enable, sau đó regenerate profile. Xem thêm tài liệu Microsoft Learn về iOS deployment để đối chiếu checklist capability.

Câu hỏi thường gặp

Có thể build .NET MAUI iOS trên Windows runner không?

Không. iOS toolchain (Xcode, actool, ibtool) chỉ chạy trên macOS. Bạn có thể phát triển và debug MAUI iOS từ máy Windows bằng cách kết nối với Mac remote qua "Pair to Mac" trong Visual Studio, nhưng bước build IPA cuối cùng bắt buộc chạy trên runner macOS trong pipeline.

GitHub Actions có miễn phí cho .NET MAUI không?

Public repo được dùng runner Linux, Windows và macOS miễn phí không giới hạn. Private repo có 2000 phút Linux/tháng miễn phí ở tier Free, nhưng phút macOS tính 10x nên hết rất nhanh. Plan Team ($4/user/tháng) cấp 3000 phút và nhiều dự án MAUI thương mại thường chọn Team hoặc Enterprise.

Làm sao tự động tăng build number cho iOS và Android trong GitHub Actions?

Dùng biến ${{ github.run_number }} truyền vào tham số MSBuild -p:ApplicationVersion=. Số này tăng tự động mỗi lần workflow chạy và đồng bộ giữa iOS (CFBundleVersion) và Android (versionCode). Không cần commit ngược version bump vào repo.

fastlane có bắt buộc để deploy .NET MAUI không?

Không bắt buộc, nhưng thực tế rất khó thay thế. Với Google Play có r0adkll/upload-google-play action đơn giản hơn. Với TestFlight, các API alternative như xcrun altool đã bị Apple deprecated tháng 10/2023, hiện chỉ còn xcrun notarytool (không upload TestFlight) và fastlane pilot là hai lựa chọn được maintain.

Có nên tự host runner để build .NET MAUI không?

Chỉ nên nếu bạn build trên 500 lần/tháng hoặc cần cấu hình đặc biệt (M2 Max, RAM 64GB). Self-hosted macOS runner cần Mac vật lý (Apple cấm ảo hoá macOS trên hardware không phải Apple), cần bảo trì Xcode update, và cần cô lập bảo mật. Với dự án MAUI thương mại thông thường, chi phí self-host cao hơn GitHub-hosted trong 2 năm đầu.

Priya Sharma
Về Tác Giả Priya Sharma

Cross-platform engineering lead who's shipped apps to millions on both Play Store and App Store. Believes shared codebases shouldn't mean shared mediocrity.