.NET MAUI iOS -sovelluksen koodiallekirjoituksen automatisointi tarkoittaa käytännössä fastlane matchin, App Store Connect API -avaimen ja GitHub Actions -macos-runnerin yhdistämistä yhdeksi työnkuluksi, joka noutaa varmenteet, tuottaa allekirjoitetun .ipa-tiedoston ja lähettää sen TestFlightiin ilman että kenenkään Macia tarvitaan välissä. Tämä opas käy putken läpi ylhäältä alas: match-repositorion alustus, salaisuudet, macos-15-ajurin määrittely, dotnet publish ja pahimmat kompastuskivet. Useimmat tiimit ohittavat App Store Connect API -avaimen luomisen ja jäävät kiinni kahden tekijän tunnistautumiseen, joten aloitetaan sieltä.
Fastlane match säilyttää iOS-varmenteet ja provisioning-profiilit salattuna Git-repossa, jotta jokainen tiimin jäsen ja CI-runner saa täsmälleen samat allekirjoitusmateriaalit.
App Store Connect API -avain (Key ID, Issuer ID ja .p8-tiedosto) korvaa Apple ID -kirjautumisen CI:ssä ja välttää 2FA-tarkistuskoodin syöttämisen.
GitHub Actionsin macos-15-runner on syyskuussa 2026 tuettu vaihtoehto .NET 10 MAUI iOS -julkaisuille. Linux- ja Windows-runnerit eivät voi allekirjoittaa iOS-sovellusta.
.p8-avain, .p12-varmenne ja match-passphrase tallennetaan GitHub Secretsiin base64-koodattuna, koska binääriä ei voi tallentaa suoraan.
TestFlight-lataus kannattaa hoitaa fastlane pilot upload -komennolla altoolin sijaan, koska se antaa selkeämmät virheilmoitukset ja hoitaa ITMS-käsittelyn odottamisen.
macOS-runnerit maksavat 10× Linux-runnerin verran, joten iOS-työnkulku kannattaa laukaista vain julkaisutageista, ei jokaisesta pushista.
Miksi automatisoida iOS-allekirjoitus MAUI-projektissa?
Olen nähnyt aivan liian monta MAUI-tiimiä, jossa iOS-julkaisu on yhden ihmisen Macin varassa. Kun se ihminen on lomalla, seuraava build särkyy, koska joku vaihtoi kehittäjätunnuksensa salasanaa tai varmenne vanheni ilman että siitä kertoi kukaan. Automatisointi ei ole vain tehokkuutta, vaan puhdas bussitesti. Kun putki tuottaa allekirjoitetun .ipa:n jokaisesta merkitystä tagista, kuka tahansa tiimissä voi tehdä julkaisun katsomatta Xcodea silmiin.
Toinen syy on virheellinen käsitys siitä, että Xamarin.iOS:n aikainen "signaus tehdään Visual Studiossa" pätisi yhä MAUI:n aikaan. Se ei päde. .NET 10 MAUI:ssa dotnet publish hoitaa allekirjoituksen kokonaan MSBuild-ominaisuuksilla, ja mikäli varmenne ja provisioning-profiili ovat keychainissa oikein, komentorivi allekirjoittaa suoraan. Tämä avaa ovet CI:lle, mutta samalla se tarkoittaa, että sinun vastuullasi on tuoda allekirjoitusmateriaalit runnerin keychainiin ennen buildia.
Kolmas syy on jäljitettävyys. Kun jokainen julkaisu menee saman GitHub Actions -työnkulun läpi, saat Git-taggauksen, workflow-lokin ja build-artifaktin, jotka voi linkittää yhteen. Manuaalinen "lähetin sen Xcodesta viime perjantaina" -prosessi ei tarjoa mitään näistä. Jos ylläpität AOT-käännöksellä optimoitua MAUI-sovellusta tuotannossa, jäljitettävyys on välttämätöntä, koska sinun täytyy tietää tarkalleen mikä commit meni mihinkin buildiin, jotta suorituskykytelemetria on ymmärrettävissä.
Mitä tarvitset ennen aloittamista
Käyn nämä läpi tarkistuslistana, koska yksikin puuttuva pala pysäyttää työnkulun myöhemmin. Kaikki maksullinen tulee Applen puolelta, muu on ilmaista.
Apple Developer Program -tilaus (99 $/vuosi) sekä pääkäyttäjän tai App Manager -tason oikeudet App Store Connectissa. Reader-taso ei riitä API-avaimen luomiseen.
Bundle Identifier rekisteröitynä Certificates, Identifiers & Profiles -osiossa (esim. com.example.mauitodo). Tämän on täsmättävä MAUI-projektin ApplicationId-arvoon.
Yksityinen Git-repositorio matchin varmenteille. Suositellaan erillistä repoa (esim. ios-signing-secrets), johon on kirjoitusoikeus GitHub PAT:llä.
Homebrew ja fastlane paikallisesti sillä koneella, jolla alustat matchin ensimmäistä kertaa. CI ei tee alustusta, vain käyttöä.
.NET 10 SDK sekä dotnet workload install maui. Tarkista global.json-tiedostolla, että sama versio pinnataan sekä paikallisesti että CI:ssä.
Xcode 16.4 tai uudempi (macos-15-imagen mukana). Ennen kuin ajat mitään, tarkista xcodebuild -version.
Mikä on fastlane match ja miten se toimii?
Fastlane match ratkaisee sen ikuisen "toimii minun koneellani mutta ei CI:ssä" -ongelman, kun kyse on iOS-varmenteista ja provisioning-profiileista. Idea on yksinkertainen: kaikki tiimin allekirjoitusmateriaalit tallennetaan salattuina yhteen Git-repoon, ja jokainen kone (kehittäjä tai CI) noutaa ne yhdellä komennolla ennen buildia. Ei enää keychain-vientejä, sähköpostilla lähetettyjä .p12-tiedostoja tai Slack-viestejä otsikolla "kellä on uusin ad-hoc-profiili".
Käytännössä repositoriossa on kaksi kansiota, certs/ ja profiles/, ja tiedostot on salattu OpenSSL:llä matchin passphrasella. Kun ajat fastlane match appstore, match:
Vahvistaa Apple Developer -portaalista, että profiilit ovat vielä voimassa; jos eivät, luo uudet.
Match tukee kolmea "storage modea": Git (yleisin), Google Cloud Storage ja Amazon S3. Aloita Gitistä, koska se on ilmainen ja auditoinnin kannalta helpoin. Match luo eri varmenne- ja profiilisarjan jokaiselle tyypille: development, adhoc, appstore ja enterprise. Tuotantojulkaisu käyttää aina appstore-tyyppiä.
App Store Connect API -avaimen luonti ja tallennus GitHub Secretsiin
Tämä on se vaihe, jonka useimmat tiimit ohittavat, ja sitten iOS-buildit alkavat epäonnistua satunnaisesti 2FA-haasteisiin. App Store Connect API -avain on pysyvä tunniste, joka toimii CI:ssä ilman ihmistä. Törmäsin tähän itse ensimmäistä kertaa, kun eräs julkaisuputki särkyi täsmälleen sillä hetkellä, kun olin lentokoneessa, enkä pystynyt hyväksymään Apple-tarkistuskoodia. Sen jälkeen otin API-avaimen käyttöön kaikissa asiakasprojekteissa.
1. Luo avain App Store Connectissa
Kirjaudu App Store Connectiin ja mene kohtaan Users and Access → Integrations → App Store Connect API.
Klikkaa Generate API Key. Anna nimi (esim. MAUI GitHub Actions) ja valitse rooliksi App Manager. Developer-rooli ei riitä TestFlight-lataukseen.
Lataa avaintiedosto (AuthKey_XXXXXXXXXX.p8). Voit ladata sen vain kerran. Talleta se salatusti (esim. 1Password) heti.
Kopioi talteen Issuer ID (näkyy sivun yläreunassa, UUID-muoto) ja Key ID (10-merkkinen tunnus).
2. Koodaa .p8-tiedosto base64:ksi
GitHub Secrets ei ota vastaan binääritiedostoja, joten .p8-avain täytyy koodata base64:ksi ennen tallennusta:
base64 -i AuthKey_ABCDE12345.p8 | pbcopy
# Sisältö on nyt leikepöydällä, liitä se GitHub-salaisuuteen APP_STORE_CONNECT_API_KEY_B64
3. Tallenna salaisuudet GitHub-repoon
Mene MAUI-projektin repoon → Settings → Secrets and variables → Actions → New repository secret ja luo seuraavat:
MATCH_GIT_BASIC_AUTHORIZATION: base64-koodaus muodossa github-käyttäjä:PAT (Personal Access Token, jolla on repo-oikeus signing-repoon).
# MATCH_GIT_BASIC_AUTHORIZATION lasketaan näin:
echo -n "sofia:ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" | base64
fastlane matchin alustus ja Matchfile
Alustaminen tehdään kerran, paikallisesti. Sen jälkeen CI vain lukee valmiita salattuja materiaaleja. Asenna fastlane, jos et vielä ole:
brew install fastlane
cd MyMauiApp
fastlane init # valitse "Manual setup"
fastlane match init
Match kysyy storage moden (valitse git) ja Git-URLin (esim. [email protected]:sofia/ios-signing-secrets.git). Tuloksena syntyy fastlane/Matchfile, jota kannattaa siistiä hieman:
git_url("https://github.com/sofia/ios-signing-secrets")
storage_mode("git")
type("appstore") # oletustyyppi, voidaan yliajaa lanessa
app_identifier(["com.example.mauitodo"])
username("[email protected]") # käytetään vain paikallisesti; CI:ssä API-avain
readonly(ENV["CI"] == "true") # CI ei koskaan luo uusia varmenteita
Tämän jälkeen luo tuotantoprofiili ensimmäistä kertaa paikallisesti:
fastlane match appstore
Match luo distribution-varmenteen, App Store -provisioning-profiilin, salaa ne MATCH_PASSWORDilla ja pushaa signing-repoon. Nyt CI voi noutaa ne. Jos haluat myös kehitysprofiilin (esim. simulaattoribuildeja varten), toista komento tyypillä development.
GitHub Actions -työnkulku iOS-julkaisulle
Nyt palaset ovat kasassa: API-avain, match-repo, salaisuudet. Alla on koko työnkulku, joka laukaistaan Git-tagista muodossa v*.*.*. Tallenna se tiedostoon .github/workflows/ios-release.yml:
Timeout 45 minuuttia: MAUI iOS -buildin ensimmäinen ajo macos-15:llä vie 12–18 minuuttia, kun workload-lataus on mukana. Cache pienentää tätä myöhemmin.
ApplicationVersion=${{ github.run_number }}: TestFlight vaatii, että jokainen upload on eri build-numero. Käyttämällä run_number-arvoa saat monotonisesti kasvavan numeron ilman käsin bumppaamista.
Profile UUID haetaan security- ja plutil-työkaluilla, koska MSBuild tarvitsee UUID:n eikä profiilin nimeä.
ArchiveOnBuild=true: tämä on MAUI 10:n oikea tapa tuottaa .ipa. Vanhat oppaat käyttävät msbuild /t:Archive-komentoa, joka ei enää toimi.
dotnet publish ja allekirjoitetun .ipa-tiedoston tuottaminen
MAUI 10 muutti iOS-julkaisun perusteellisesti. Nyt dotnet publish tekee kaiken: käännöksen, AOT-käännöksen, allekirjoituksen ja .ipa-paketoinnin, kunhan annat oikeat MSBuild-ominaisuudet. Tässä ne, jotka tarvitset iOS-julkaisuun 2026:
MSBuild-ominaisuus
Arvo
Merkitys
ArchiveOnBuild
true
Tuottaa .ipa-tiedoston .app-kansion sijaan
RuntimeIdentifier
ios-arm64
Kohde-arkkitehtuuri; App Store vaatii arm64
CodesignKey
"Apple Distribution: ..."
Varmenteen common name täsmällisesti
CodesignProvision
Profiilin UUID
Ei nimi, vaan UUID
ApplicationDisplayVersion
1.2.3
Käyttäjän näkemä versio (CFBundleShortVersionString)
ApplicationVersion
456
Sisäinen build-numero (CFBundleVersion)
Näiden lisäksi projektin .csproj-tiedostossa kannattaa lukita julkaisumuoto niin, että se ei jää työpöydän oletuksiin. Alla minimaalinen esimerkki:
Tarkista lopputulos ennen upload-vaihetta ajamalla paikallisesti codesign -dv --verbose=4 artifacts/MyMauiApp.ipa. Jos allekirjoitus on rikki, komento kertoo täsmällisesti, mikä puuttuu. Applen oma MS Learn -dokumentaatio iOS-julkaisusta CLI:llä on hyvä referenssi, jos joudut debuggaamaan MSBuild-parametreja. Sivun MAUI iOS -julkaisuoppaan päähakemistoon kannattaa myös lisätä kirjanmerkki, koska sinne kootaan uusimmat CLI- ja Xcode-yhteensopivuustaulukot.
TestFlightiin lataaminen: pilot vs. altool
Kolme tapaa ladata .ipa TestFlightiin: Applen oma xcrun altool, uudempi xcrun notarytool-korvaava xcrun altool, ja fastlanen pilot. Suosittelen pilotia CI-työnkulussa, mutta on hyvä tietää erot:
Kriteeri
fastlane pilot
xcrun altool
Autentikointi
App Store Connect API -avain
API-avain tai käyttäjätunnus + app-specific password
Virheilmoitukset
Selkeät, suomennetut ITMS-koodit
Raakoja XML-vastauksia
Odottaa ITMS-käsittelyä
Kyllä (--skip_waiting_for_build_processing false)
Ei
Testers & ryhmien hallinta
Kyllä
Ei
Ylimääräiset riippuvuudet
Ruby + fastlane
Ei mitään (kuuluu Xcodeen)
Käytännössä pilot on parempi CI:ssä, koska sen virheilmoitukset kertovat mikä meni pieleen. Vika ei ole aina allekirjoituksessa, vaan usein Info.plistin puuttuvassa ITSAppUsesNonExemptEncryption-avaimessa tai puuttuvassa kuvakkeessa. altool vain tulostaa "The operation couldn't be completed" -viestin ja jättää sinut arvaamaan.
Jos haluat vielä pidemmälle vietyä automaatiota (esim. release notes -kentän täyttöä ja testers-ryhmien lisäämistä samalla laneilla), kannattaa käydä läpi myös .NET MAUI push-ilmoitusten integraatio-oppaani, jossa käsittelen samaa APNs-avainten hallintaa ja miten ne suhteutuvat App Store Connect API -avaimeen (ne eivät ole sama asia).
Yleiset virheet ja niiden korjaaminen
Kirjaan tähän ne viisi virhettä, jotka olen nähnyt vuoden aikana toistuvan MAUI-tiimien CI-lokeissa. Jos työnkulkusi epäonnistuu, aloita tarkistamalla nämä.
"No profile for team '...' matching 'match AppStore com.example.mauitodo' found"
Match on ajettu, mutta MSBuild ei löydä profiilia keychainista. Syy on lähes aina se, että CodesignProvision-parametriin annettiin profiilin nimi UUID:n sijaan. MAUI 10 vaatii UUID:n. Ratkaisu: käytä yllä olevaa security cms-komentoa profile UUID:n hakemiseen.
"Xcode version X is not supported by the .NET MAUI workload"
Tulee näkyviin kun macos-runner päivittyy uudempaan Xcodeen ennen kuin MAUI-workload on ehtinyt tukea sitä. Ratkaisu: lukitse Xcode-versio sudo xcode-select -s /Applications/Xcode_16.4.app. GitHubin macos-15-image sisältää useita Xcode-versioita yhtä aikaa.
"ITMS-90809: Deprecated API Usage"
Apple hylkää säännöllisesti vanhoja API:ta. Yleisin syy MAUI-sovelluksessa on vanha UIWebView, joka voi tulla riippuvuutena kolmannen osapuolen NuGet-paketista. Aja nm -u MyMauiApp.app/MyMauiApp | grep UIWebView ja vaihda syyllinen pakettien versiot uusiin.
"Invalid provisioning profile. The bundle contains invalid entitlements"
Provisioning-profiilissa on esim. push-notifikaatioiden entitlement, mutta App ID -konfiguraatiossa capability on kytketty pois päältä (tai päinvastoin). Käy Certificates & Profiles -paneelissa läpi, että App ID:n capabilityt vastaavat sitä, mitä Entitlements.plist pyytää, ja aja sitten fastlane match nuke distribution + uudelleenluonti.
"MATCH_PASSWORD is not set"
Salaisuus on määritelty repossa mutta ei siirretty env:-lohkoon. GitHub Actions ei automaattisesti anna kaikkia salaisuuksia joka stepille; jokainen step, joka niitä tarvitsee, tarvitsee myös env:-määrittelyn. Tarkista, että jokainen match- ja pilot-step listaa tarvittavat muuttujat.
Jos rakennat monimutkaisempaa MAUI-projektia, jossa navigointi ja tila kasvavat, kannattaa lukea myös .NET MAUI Shell -navigointi -oppaani, jossa syvennän deep linking -konfiguraation, joka linkittyy tähän julkaisuputkeen (Universal Links -tuki vaatii saman App ID:n ja Associated Domains -entitlementin).
Buildien nopeutus: cache ja kustannussäästöt
macOS-runnerit maksavat GitHubin hinnoittelun mukaan noin 0,08 $/minuutti (10× Linux-runner). Yhden iOS-buildin hinta on siis 1–1,5 $, ja jos jokainen PR laukaisee buildin, kuukausikustannus nousee nopeasti kolminumeroiseksi. Kolme tapaa säästää:
Aja iOS-build vain tageista, kuten yllä olevassa YAML:ssä. Pull request -tarkistuksissa aja vain dotnet build -f net10.0 Linux-runnerilla, joka paljastaa syntaksivirheet ilman iOS-arkistoa.
Välitallenna workload: cachea ~/.dotnet/ ja ~/Library/Caches/Xamarin/. Ensimmäinen ajo latautuu 8 min, seuraavat 2 min.
Käytä concurrency-lohkoa, jotta uusi push samaan tagiin peruu edellisen ajon: concurrency: { group: ios-release, cancel-in-progress: true }.
Voinko allekirjoittaa .NET MAUI iOS -sovelluksen ilman Macia?
Et paikallisessa kehityksessä, mutta voit CI:ssä käyttämällä GitHub Actionsin macos-15-runneria. Tämä opas näyttää, miten koko allekirjoitus- ja TestFlight-julkaisu tapahtuu ilman että tiimin kenelläkään tarvitsee omaa Macia.
Mikä on fastlane matchin ja Xcoden "automatic signing" -toiminnon ero?
Automatic signing luo ja päivittää profiilit sen mukaan, mitä paikallinen kone tarvitsee, eikä siis ole toistettava. Match tallentaa saman varmenteen ja profiilin salattuna Git-repoon, jolloin jokainen kone (CI mukaan lukien) saa täsmälleen samat allekirjoitusmateriaalit. Match tuottaa toistettavat buildit, automatic signing ei.
Miksi App Store Connect API -avain on parempi kuin Apple ID + app-specific password?
API-avain ei koskaan aiheuta 2FA-tarkistusta ja voidaan rajoittaa vain tiettyihin oikeuksiin (esim. TestFlight-lataus). App-specific password toimii vielä, mutta Apple ilmoitti syksyllä 2024 sen poistamisesta CI-käytöstä, ja nyt syyskuussa 2026 se on virallisesti "deprecated for automated workflows".
Kuinka päivitän allekirjoitusprofiilit vanhentumisen jälkeen?
Aja paikallisesti fastlane match nuke distribution, joka poistaa vanhat, sitten fastlane match appstore, joka luo uudet ja pushaa signing-repoon. CI:n readonly(true)-asetus estää saman tapahtumasta CI-ajossa, mikä on tarkoitus: vain ihminen saa luoda uusia varmenteita.
Voinko käyttää Azure DevOpsia GitHub Actionsin sijaan?
Kyllä, ja tämän oppaan konseptit siirtyvät suoraan. Tärkein ero on macOS-agentin määrittely (vmImage: 'macOS-15') ja salaisuuksien tallennus Library-osioon Secrets-osion sijaan. dotnet publish-komennot ovat identtiset molemmissa alustoissa.
Mikä on ApplicationVersionin ja ApplicationDisplayVersionin ero?
ApplicationDisplayVersion (Info.plistissä CFBundleShortVersionString) on käyttäjän näkemä versio, esim. 1.2.3. ApplicationVersion (CFBundleVersion) on sisäinen build-numero, jonka TestFlight vaatii uniikiksi jokaiselle uploadille. Käytä github.run_number-arvoa jälkimmäiseen, jotta se kasvaa automaattisesti.
Push-ilmoitukset .NET MAUI -sovelluksessa vaativat sekä Firebase FCM:n Androidille että APNs:n iOS:lle. Tässä käytännön oppaassa käydään läpi HTTP v1 -rajapinta, token-rekisteröinti, notification channels, deep linking Shell-reitille ja tuotannon kompastuskivet.