Ενημερώθηκε: 16 Σεπτεμβρίου 2026
Το GitHub Actions για .NET MAUI CI/CD σου επιτρέπει να κάνεις build, sign και deploy την εφαρμογή σου σε Android και iOS από ένα ενιαίο workflow, χωρίς Azure DevOps ή Jenkins. Χρειάζεσαι έναν windows-latest ή ubuntu-latest runner για το Android APK/AAB, έναν macos-14 runner για το iOS .ipa, τα κατάλληλα secrets (keystore, provisioning profile, App Store Connect API key) και ένα YAML workflow που ενσωματώνει το dotnet publish με fastlane για upload στα stores.
Γιατί GitHub Actions για .NET MAUI CI/CD
Έχω στήσει pipelines για MAUI (και Xamarin.Forms πριν από αυτό) σε Azure DevOps, App Center, Bitrise και GitHub Actions. Οι περισσότερες ομάδες παραλείπουν αυτό το βήμα και μετά κολλάνε στο "στο μηχάνημά μου δουλεύει" την Παρασκευή στις 6μμ πριν από ένα release. Ήταν εκεί, το έζησα, δεν το προτείνω σε κανέναν.
Το GitHub Actions κερδίζει σήμερα για τρεις πρακτικούς λόγους. Πρώτον, ο κώδικας και το CI ζουν στο ίδιο repository. Δεύτερον, τα macOS 14 (Apple Silicon) runners υποστηρίζουν το dotnet publish -f net9.0-ios out-of-the-box. Και τρίτον, το ecosystem των actions (setup-dotnet, upload-artifact, fastlane) καλύπτει το 90% όσων χρειάζεσαι.
Το App Center έχει ήδη ανακοινώσει sunset για τον Μάρτιο του 2025, οπότε αν είσαι ακόμα εκεί, η μετάβαση είναι υποχρεωτική. Το Azure DevOps παραμένει καλή επιλογή για enterprise setups με πολλαπλά repos, αλλά για single-repo MAUI apps το GitHub Actions προσφέρει καλύτερη ενσωμάτωση με pull requests, environment protection rules και OIDC για cloud deployments. Επιπλέον, τα free minutes για public repos είναι απεριόριστα και για private repos έχεις 2.000 λεπτά/μήνα στο δωρεάν πλάνο, δηλαδή περίπου 40 πλήρη builds ενός μεσαίου MAUI project.
Για την πλήρη ροή release μετά το CI, δες και τη δημοσίευση .NET MAUI σε App Store και Google Play που καλύπτει τα manual review steps.
Αρχιτεκτονική Pipeline: Runners, Jobs και Matrix
Ο βασικός κανόνας είναι ένας: το iOS build απαιτεί υποχρεωτικά macOS runner, επειδή το Xcode toolchain (xcodebuild, codesign, security) υπάρχει μόνο εκεί. Το Android build μπορεί να τρέξει σε Ubuntu ή Windows runner. Τα Ubuntu runners είναι δύο φορές φτηνότερα από τα Windows και τέσσερις φορές φτηνότερα από τα macOS, οπότε η επιλογή είναι εύκολη.
Η αρχιτεκτονική βγαίνει ως εξής:
- Job "build-android" σε
ubuntu-latest - Job "build-ios" σε
macos-14(Apple Silicon, Xcode 16) - Job "deploy" που εξαρτάται (
needs) και από τα δύο, για upload σε stores
Και τα δύο build jobs μπορούν να τρέξουν παράλληλα, μειώνοντας το wall-clock time. Δες τη βασική δομή του .github/workflows/release.yml:
name: MAUI Release Pipeline
on:
push:
tags: ['v*.*.*']
workflow_dispatch:
inputs:
channel:
description: 'Release channel'
required: true
default: 'internal'
type: choice
options: [internal, beta, production]
env:
DOTNET_VERSION: '9.0.x'
MAUI_VERSION: '9.0.0'
jobs:
build-android:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Install MAUI workload
run: dotnet workload install maui-android
- name: Build & sign Android
run: |
dotnet publish src/MyApp/MyApp.csproj \
-f net9.0-android \
-c Release \
-p:AndroidPackageFormat=aab \
-p:AndroidKeyStore=true \
-p:AndroidSigningKeyStore=$RUNNER_TEMP/upload.keystore \
-p:AndroidSigningStorePass=${{ secrets.ANDROID_KEYSTORE_PASSWORD }} \
-p:AndroidSigningKeyAlias=${{ secrets.ANDROID_KEY_ALIAS }} \
-p:AndroidSigningKeyPass=${{ secrets.ANDROID_KEY_PASSWORD }}
- uses: actions/upload-artifact@v4
with:
name: android-aab
path: '**/*-Signed.aab'
build-ios:
runs-on: macos-14
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Install MAUI workload
run: dotnet workload install maui-ios
- name: Select Xcode 16.1
run: sudo xcode-select -s /Applications/Xcode_16.1.app
deploy:
needs: [build-android, build-ios]
runs-on: ubuntu-latest
steps:
- run: echo "Deploy step goes here"
Προετοιμασία repository: Secrets και Environments
Πριν γράψεις οποιοδήποτε workflow, χρειάζεσαι τα ακόλουθα ως GitHub Secrets (Settings → Secrets and variables → Actions). Δεν κάνεις commit ΤΙΠΟΤΑ από αυτά. Ούτε σαν "temp file", ούτε σαν "θα το βγάλω μετά". Ποτέ.
Android secrets
ANDROID_KEYSTORE_BASE64, δηλαδή το.keystorefile κωδικοποιημένο σε base64 (base64 -i upload.keystore | pbcopy)ANDROID_KEYSTORE_PASSWORD,ANDROID_KEY_ALIAS,ANDROID_KEY_PASSWORDGOOGLE_PLAY_SERVICE_ACCOUNT_JSON: service account JSON με πρόσβαση στο Google Play Developer API
iOS secrets
IOS_P12_CERTIFICATE_BASE64: το.p12distribution certificate σε base64IOS_P12_PASSWORD: το password του .p12IOS_PROVISIONING_PROFILE_BASE64: το.mobileprovisionσε base64APP_STORE_CONNECT_API_KEY_ID,APP_STORE_CONNECT_ISSUER_ID,APP_STORE_CONNECT_API_KEY_BASE64: το p8 API key σε base64KEYCHAIN_PASSWORD: random password για το προσωρινό keychain του runner
Στη συνέχεια δημιούργησε Environments (Settings → Environments): internal, beta, production. Στο production environment ενεργοποίησε "Required reviewers" ώστε να χρειάζεται manual approval πριν το upload στο App Store. Αυτή η επιπλέον πόρτα έχει σώσει release πολλές φορές στην καριέρα μου. Ένας συνάδελφος τρέχει το workflow, ο άλλος approves αφού δει το build number και τα release notes. Απλό, αλλά έχει γλυτώσει και εμένα και άλλους από πραγματικά ενοχλητικά rollbacks.
Android build και signing στο GitHub Actions
Για το Android, το .NET MAUI υποστηρίζει native signing μέσα από τα MSBuild properties. Δεν χρειάζεσαι fastlane για signing. Το dotnet publish κάνει τη δουλειά μόνο του. Το βασικό βήμα είναι να αποκωδικοποιήσεις το keystore από το secret και να το βάλεις σε ένα temporary path:
- name: Decode Android keystore
env:
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
run: |
echo "$KEYSTORE_BASE64" | base64 --decode > $RUNNER_TEMP/upload.keystore
ls -la $RUNNER_TEMP/upload.keystore
- name: Restore workloads
run: dotnet workload restore src/MyApp/MyApp.csproj
- name: Publish Android AAB
run: |
dotnet publish src/MyApp/MyApp.csproj \
-f net9.0-android \
-c Release \
-p:ApplicationDisplayVersion=1.4.0 \
-p:ApplicationVersion=${{ github.run_number }} \
-p:AndroidPackageFormat=aab \
-p:AndroidKeyStore=true \
-p:AndroidSigningKeyStore=$RUNNER_TEMP/upload.keystore \
-p:AndroidSigningStorePass=${{ secrets.ANDROID_KEYSTORE_PASSWORD }} \
-p:AndroidSigningKeyAlias=${{ secrets.ANDROID_KEY_ALIAS }} \
-p:AndroidSigningKeyPass=${{ secrets.ANDROID_KEY_PASSWORD }} \
-o ./artifacts/android
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: android-release
path: ./artifacts/android/*-Signed.aab
retention-days: 14
Ο συνδυασμός ApplicationDisplayVersion (semver) και ApplicationVersion (integer, το versionCode για την Google Play) είναι κρίσιμος. Η Google Play απορρίπτει κάθε upload όπου το versionCode δεν είναι αυστηρά μεγαλύτερο από το προηγούμενο. Χρησιμοποιώντας το github.run_number εξασφαλίζεις μονοτονική αύξηση χωρίς manual παρέμβαση.
Για ακόμα καλύτερα αποτελέσματα, ενεργοποίησε το AndroidLinkMode=SdkOnly για μικρότερο AAB και το AndroidPackageFormat=aab (όχι apk). Η Google Play απαιτεί App Bundle format από τον Αύγουστο του 2021 για κάθε νέο app. Το APK δουλεύει μόνο για internal distribution ή sideloading. Αν θες να δεις τα διαθέσιμα MSBuild properties, ο επίσημος πίνακας του .NET for Android είναι η πηγή αλήθειας.
iOS build και signing σε macOS runners
Το iOS είναι το σημείο όπου οι περισσότερες ομάδες παραλείπουν βήματα και μετά ξοδεύουν μια ολόκληρη μέρα στο debug. (Και ναι, το έχω κάνει μία Παρασκευή απόγευμα, δεν το συνιστώ.) Χρειάζεσαι τρία πράγματα σωστά για να δουλέψει το signing: ένα Distribution Certificate (.p12) στο keychain του runner, ένα Provisioning Profile (.mobileprovision) στο σωστό directory, και το Team ID/Bundle ID να ταιριάζει με ό,τι υπάρχει στο csproj.
Το βήμα setup δείχνει έτσι:
- name: Import Apple certificate & provisioning profile
env:
P12_BASE64: ${{ secrets.IOS_P12_CERTIFICATE_BASE64 }}
P12_PASSWORD: ${{ secrets.IOS_P12_PASSWORD }}
PROFILE_BASE64: ${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
# Δημιουργία temporary keychain
KEYCHAIN_PATH=$RUNNER_TEMP/build.keychain
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
# Import του .p12 certificate
echo "$P12_BASE64" | base64 --decode > $RUNNER_TEMP/cert.p12
security import $RUNNER_TEMP/cert.p12 \
-P "$P12_PASSWORD" \
-A -t cert -f pkcs12 -k $KEYCHAIN_PATH
security list-keychain -d user -s $KEYCHAIN_PATH
# Εγκατάσταση provisioning profile
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
echo "$PROFILE_BASE64" | base64 --decode > \
~/Library/MobileDevice/Provisioning\ Profiles/build.mobileprovision
- name: Publish iOS IPA
run: |
dotnet publish src/MyApp/MyApp.csproj \
-f net9.0-ios \
-c Release \
-p:ApplicationDisplayVersion=1.4.0 \
-p:ApplicationVersion=${{ github.run_number }} \
-p:RuntimeIdentifier=ios-arm64 \
-p:ArchiveOnBuild=true \
-p:CodesignKey="Apple Distribution: My Company (TEAM123ABC)" \
-p:CodesignProvision="MyApp App Store" \
-o ./artifacts/ios
Δύο τιμές πρέπει να ταιριάζουν ακριβώς. Το CodesignKey είναι το common name του certificate όπως εμφανίζεται στο Keychain Access (μπορείς να το επαληθεύσεις με security find-identity -v -p codesigning). Το CodesignProvision είναι το name του provisioning profile (όχι το UUID, όχι το filename). Αν κάτι δεν ταιριάζει, το xcodebuild αποτυγχάνει με ένα μπερδεμένο μήνυμα τύπου "No profiles matching 'X' found".
Για εναλλακτική προσέγγιση, δες τον επίσημο οδηγό της Microsoft για iOS deployment, ο οποίος τεκμηριώνει όλα τα MSBuild properties. Για αυτόματη διαχείριση certificates σε ομάδα (χωρίς manual export σε .p12), δες το fastlane match documentation. Γράφει τα certs σε ένα private git repo κρυπτογραφημένα και τα κάνει sync σε κάθε machine.
Δημοσίευση σε Google Play και App Store με fastlane
Αν και το dotnet publish παράγει το τελικό artifact, το upload στα stores είναι πολύ πιο εύκολο με fastlane. Το fastlane είναι Ruby-based, εγκαθίσταται σε 30 δευτερόλεπτα και έχει δύο βασικά actions για MAUI: supply (Google Play) και pilot (TestFlight).
Στο repo σου, δημιούργησε ένα fastlane/Fastfile:
default_platform(:android)
platform :android do
desc "Upload AAB to Google Play internal track"
lane :internal do
upload_to_play_store(
package_name: "com.mycompany.myapp",
track: "internal",
aab: "./artifacts/android/MyApp-Signed.aab",
json_key_data: ENV["GOOGLE_PLAY_SERVICE_ACCOUNT_JSON"],
skip_upload_metadata: true,
skip_upload_images: true,
skip_upload_screenshots: true
)
end
end
platform :ios do
desc "Upload IPA to TestFlight"
lane :beta do
api_key = app_store_connect_api_key(
key_id: ENV["APP_STORE_CONNECT_API_KEY_ID"],
issuer_id: ENV["APP_STORE_CONNECT_ISSUER_ID"],
key_content: ENV["APP_STORE_CONNECT_API_KEY_BASE64"],
is_key_content_base64: true,
duration: 1200
)
upload_to_testflight(
api_key: api_key,
ipa: "./artifacts/ios/MyApp.ipa",
skip_waiting_for_build_processing: true
)
end
end
Και στο workflow, μετά το build:
- name: Setup Ruby & fastlane
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.3'
bundler-cache: true
- name: Deploy to Google Play internal
if: matrix.platform == 'android'
env:
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}
run: bundle exec fastlane android internal
- name: Deploy to TestFlight
if: matrix.platform == 'ios'
env:
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
APP_STORE_CONNECT_API_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_API_KEY_BASE64 }}
run: bundle exec fastlane ios beta
Το App Store Connect API key αντικαθιστά τη χρήση Apple ID/password (που έχει καταργηθεί ούτως ή άλλως λόγω 2FA). Το ίδιο key μπορεί να ανακληθεί οποιαδήποτε στιγμή από το App Store Connect χωρίς να πειραχτεί ο λογαριασμός. Για ολοκληρωμένο workflow release που περιλαμβάνει και τα manual review steps, δες τον οδηγό δημοσίευσης σε App Store και Google Play.
Αυτόματο versioning και build caching
Το βασικό διακύβευμα σε ένα CI/CD pipeline είναι ο χρόνος. Ένα MAUI project από scratch μπορεί να χρειάζεται 15-25 λεπτά για full build και τα δύο platforms. Με σωστό caching, πέφτει στα 5-8 λεπτά. Δύο caches αξίζουν πραγματικά:
- name: Cache NuGet packages
uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj', '**/packages.lock.json') }}
restore-keys: |
${{ runner.os }}-nuget-
- name: Cache .NET workloads
uses: actions/cache@v4
with:
path: |
~/.dotnet/workloads
~/.dotnet/sdk-advertising
key: ${{ runner.os }}-workloads-${{ env.DOTNET_VERSION }}-${{ env.MAUI_VERSION }}
Το NuGet cache γλυτώνει τα πιο πολλά λεπτά. Τα MAUI projects τραβάνε 200-400 packages. Το workload cache είναι μικρότερο κέρδος (~30 δευτερόλεπτα) αλλά αξίζει και αυτό.
Για versioning, εκτός από το github.run_number, μπορείς να χρησιμοποιήσεις git tags σαν πηγή αλήθειας. Ένα workflow που τρέχει μόνο σε v*.*.* tags μπορεί να παράγει την έκδοση από το tag name:
- name: Parse version from tag
id: version
run: |
TAG=${GITHUB_REF#refs/tags/v}
echo "display=$TAG" >> $GITHUB_OUTPUT
echo "code=${{ github.run_number }}" >> $GITHUB_OUTPUT
- name: Publish
run: |
dotnet publish ... \
-p:ApplicationDisplayVersion=${{ steps.version.outputs.display }} \
-p:ApplicationVersion=${{ steps.version.outputs.code }}
Έτσι το tag v1.4.0 γίνεται το display version, ενώ ο μοναδικός run number γίνεται το internal build number. Και τα δύο stores δείχνουν το 1.4.0 στους χρήστες, αλλά εσωτερικά ξεχωρίζουν τα builds. Απλό και συνεπές.
Best practices και συχνά λάθη
Μερικά πράγματα που έχω μάθει με τον δύσκολο τρόπο:
1. Χώρισε PR builds από release builds
Στα PR runs δεν χρειάζεσαι signing. Ένα ξεχωριστό pr-check.yml workflow που κάνει μόνο dotnet build και dotnet test σε Ubuntu (χωρίς macOS) τρέχει σε 3 λεπτά αντί για 15. Κράτα το release workflow μόνο για tags ή manual dispatch. Για τη σχεδίαση των tests, δες τον οδηγό δοκιμών σε .NET MAUI.
2. Χρησιμοποίησε concurrency για να αποφύγεις race conditions
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: falseΑν δύο pushes γίνουν σε γρήγορη διαδοχή, το δεύτερο θα περιμένει το πρώτο. Χωρίς αυτό, ενδέχεται δύο runs να προσπαθούν να ανεβάσουν το ίδιο version number. Και αυτό μοιάζει αστείο μέχρι να συμβεί σε πραγματικό release.
3. Μην κάνεις commit το bin/obj
Οι MAUI templates παράγουν συχνά ένα Platforms/Android/Resources folder που μπερδεύει το .gitignore. Επιβεβαίωσε ότι το obj/ και bin/ είναι πραγματικά ignored. Ένα stale obj folder στο repo προκαλεί mysterious build failures (κυριολεκτικά, "δουλεύει τοπικά αλλά όχι στο CI").
4. Σεβάσου το rate limit του App Store Connect API
Το API έχει όριο ~200 requests/hour. Το fastlane pilot κάνει ~10 requests ανά upload. Αν έχεις πάνω από 15 releases την ημέρα (π.χ. σε monorepo με πολλά apps), θα χτυπήσεις το limit. Χρησιμοποίησε skip_waiting_for_build_processing: true για να αποφύγεις άσκοπα polling calls.
5. Ποτέ μην κάνεις log τα secrets
Το GitHub Actions σκιάζει αυτόματα τα secrets στα logs, αλλά αν κάνεις echo ένα base64-decoded secret σε λάθος βήμα, θα βγει καθαρό. Πάντα χρησιμοποίησε redirect σε αρχείο (> file.p12), ποτέ σε stdout.
Τέλος, αν έρχεσαι από Xamarin.Forms, θα δεις ότι το MAUI CI είναι πολύ πιο απλό. Δεν χρειάζεσαι Windows agent για Android (το Ubuntu φτάνει) και δεν χρειάζεσαι Windows agent για UWP (αφού δεν υπάρχει πια). Ένα καθαρό migration από Xamarin.Forms σε .NET MAUI συχνά μειώνει τον χρόνο CI κατά 40%. Στο τελευταίο μου migration ήταν 42%, αλλά ας πούμε ότι το στρογγυλεύω.
Συχνές ερωτήσεις
Μπορώ να χτίσω .NET MAUI iOS app στο GitHub Actions χωρίς macOS runner;
Όχι. Το iOS toolchain (xcodebuild, codesign, actool) υπάρχει μόνο σε macOS. Χρειάζεσαι υποχρεωτικά macos-13, macos-14 ή macos-latest runner για το iOS build. Για το Android, μπορείς να χρησιμοποιήσεις ubuntu-latest, που είναι και φτηνότερο.
Πόσο κοστίζει ένα τυπικό MAUI CI/CD pipeline;
Στο GitHub free tier έχεις 2.000 λεπτά/μήνα για private repos. Το macOS runner καταναλώνει με πολλαπλασιαστή 10x, οπότε πραγματικά έχεις 200 λεπτά macOS. Ένα iOS build τρέχει ~10 λεπτά, άρα έχεις χώρο για περίπου 20 iOS builds/μήνα. Για μεγαλύτερη χρήση, χρειάζεσαι Team ($4/user/μήνα) ή self-hosted macOS runners.
Χρειάζομαι fastlane αν κάνω upload στα stores;
Δεν είναι υποχρεωτικό. Μπορείς να χρησιμοποιήσεις το επίσημο google-play-github-action για Android και τα Apple API endpoints κατευθείαν για TestFlight. Αλλά το fastlane είναι battle-tested, έχει documentation για κάθε edge case και το ίδιο script τρέχει και τοπικά (χρήσιμο για emergency releases όταν το CI έχει outage).
Πώς διαχειρίζομαι διαφορετικά bundle IDs για staging και production;
Χρησιμοποίησε MSBuild configurations. Στο csproj, όρισε <ApplicationId Condition="'$(Configuration)' == 'Staging'">com.mycompany.myapp.staging</ApplicationId>. Στο workflow, πέρασε -c Staging για ένα build και -c Release για το άλλο. Χρειάζεσαι ξεχωριστό provisioning profile για κάθε bundle ID.
Γιατί αποτυγχάνει το iOS build με "No profiles matching found";
Το πιο συχνό αίτιο είναι mismatch μεταξύ του CodesignProvision που δίνεις και του πραγματικού provisioning profile name. Τρέξε security cms -D -i profile.mobileprovision στο runner και έλεγξε το <key>Name</key>. Αυτή είναι η τιμή που πρέπει να δώσεις, όχι το filename ούτε το UUID. Επίσης βεβαιώσου ότι το bundle ID στο csproj ταιριάζει ακριβώς με αυτό στο profile.
Μπορώ να χρησιμοποιήσω self-hosted Mac για iOS builds;
Ναι, και αξίζει αν κάνεις πολλά releases. Ένα Mac mini M2 (~700€) πληρώνει τον εαυτό του σε περίπου 6 μήνες σε σχέση με τα macOS-hosted minutes. Πρόσεξε όμως τα security implications: το self-hosted runner τρέχει με τα δικαιώματα του user, οπότε δεν το βάζεις σε public repo και το απομονώνεις σε VLAN.


