Atualizado: 13 de Julho de 2026
Um pipeline de CI/CD para .NET MAUI com GitHub Actions é um workflow YAML que compila, assina e distribui um app net10.0-ios, net10.0-android e net10.0-maccatalyst a partir de um único repositório, usando runners macos-15 para builds Apple e ubuntu-24.04 para Android. A maioria das equipes que herda um projeto Xamarin.Forms migrado tenta reaproveitar scripts antigos de msbuild, quebra na etapa de assinatura, e só então descobre que o workload maui precisa ser restaurado explicitamente em cada job. Honestamente, foi assim que perdi um final de semana inteiro no meu primeiro release .NET MAUI. Este guia mostra o pipeline completo, do checkout ao upload no TestFlight e Google Play.
Pré-requisitos e estrutura do repositório
Na maioria dos projetos que peguei nos últimos dois anos, o repositório tinha um .csproj multi-target no formato net10.0-android;net10.0-ios;net10.0-maccatalyst;net10.0-windows10.0.19041.0 e nenhum arquivo global.json. Isso é a primeira coisa que quebra o CI. O runner do GitHub pode ter uma SDK diferente da que a equipe usa localmente, e o build falha em NETSDK1045. Já perdi tarde da noite debugando exatamente isso.
Antes de escrever qualquer YAML, garanta o seguinte na raiz do repositório:
- Um
global.jsonfixando a versão exata do .NET 10 SDK (por exemplo,10.0.100). - Um arquivo
.editorconfige umDirectory.Build.propscompartilhado ondeApplicationVersioneApplicationDisplayVersionsão declarados como propriedades MSBuild. Nunca deixe hardcoded no.csproj. - Um projeto de testes unitários separado em
tests/que compila emnet10.0puro (sem TFMs móveis), para poder rodar em qualquer runner sem workloads. - Um arquivo
nuget.configcomitado, apontando apenas paraapi.nuget.org. Feeds internos autenticados exigem configuração adicional de secrets.
Se você está vindo de Xamarin, dê uma olhada no nosso guia de migração do Xamarin.Forms para .NET MAUI antes de configurar o pipeline. Headers de .csproj pré-migração produzem artefatos que nenhum job de assinatura consegue processar.
Workflow base do GitHub Actions para .NET MAUI
O pipeline começa com um workflow único que dispara em push na branch main e em pull requests. Divido em três jobs paralelos: test, build-android, build-ios. O truque para .NET MAUI é sempre restaurar o workload depois do checkout, porque a imagem do runner traz apenas o SDK puro. Salve o arquivo abaixo em .github/workflows/build.yml:
name: build-and-release
on:
push:
branches: [main]
tags: ['v*']
pull_request:
branches: [main]
env:
DOTNET_VERSION: '10.0.x'
DOTNET_NOLOGO: true
DOTNET_CLI_TELEMETRY_OPTOUT: true
jobs:
test:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Restore
run: dotnet restore tests/MyApp.Tests.csproj
- name: Run unit tests
run: dotnet test tests/MyApp.Tests.csproj --no-restore --logger "trx"
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results
path: '**/*.trx'
O job test roda em Ubuntu porque testes unitários puros não precisam de workloads móveis, e isso corta o custo em minutos GitHub-Actions à metade em comparação com rodar em macOS. Se seus testes tocam código específico de plataforma, mova-os para um job matrix em macos-15. Para uma cobertura mais profunda de estratégia de testes, veja o guia completo de testes em .NET MAUI.
Como construir e assinar um APK/AAB Android no CI
Android é a plataforma mais barata de rodar no CI porque não exige runner macOS. O fluxo é direto: decodificar o keystore de um secret base64, restaurar o workload maui-android, publicar o AAB assinado, e fazer upload do artefato. Adicione o job abaixo ao build.yml:
build-android:
runs-on: ubuntu-24.04
needs: test
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
- uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Cache NuGet
uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj') }}
restore-keys: nuget-${{ runner.os }}-
- name: Install MAUI workload
run: dotnet workload install maui-android --source https://api.nuget.org/v3/index.json
- name: Decode keystore
env:
KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_B64 }}
run: echo "$KEYSTORE_B64" | base64 -d > $RUNNER_TEMP/app.keystore
- name: Publish 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/MyApp/MyApp.csproj \
-c Release \
-f net10.0-android \
/p:AndroidPackageFormat=aab \
/p:AndroidKeyStore=true \
/p:AndroidSigningKeyStore=$RUNNER_TEMP/app.keystore \
/p:AndroidSigningStorePass="$KEYSTORE_PASS" \
/p:AndroidSigningKeyAlias="$KEY_ALIAS" \
/p:AndroidSigningKeyPass="$KEY_PASS" \
/p:ApplicationVersion=${{ github.run_number }}
- uses: actions/upload-artifact@v4
with:
name: android-aab
path: '**/bin/Release/net10.0-android/publish/*-Signed.aab'
Note que o keystore nunca é escrito no diretório do repositório. Ele fica só em $RUNNER_TEMP, que o runner limpa automaticamente ao final. O parâmetro ApplicationVersion deve ser um inteiro monotonicamente crescente, e usar github.run_number resolve isso sem lógica extra. Para criar o keystore inicialmente, use keytool -genkey -v -keystore app.keystore -keyalg RSA -keysize 2048 -validity 10000 -alias upload, e converta para base64 com base64 -i app.keystore | pbcopy (macOS) ou base64 -w 0 app.keystore (Linux) antes de colar como secret.
Como buildar e assinar iOS no GitHub Actions
Builds iOS são onde 80% dos pipelines falham. Aviso: preparei mais café pra essa seção do que qualquer outra na minha carreira. Você precisa de: certificado .p12 exportado do Keychain, provisioning profile .mobileprovision, App Store Connect API key para downloads automáticos, e um runner macos-15 com Xcode selecionado corretamente. Aqui vai o job completo:
build-ios:
runs-on: macos-15
needs: test
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Select Xcode
run: sudo xcode-select -s /Applications/Xcode_16.3.app
- name: Install MAUI workload
run: dotnet workload install maui-ios --source https://api.nuget.org/v3/index.json
- name: Import certificate
uses: apple-actions/import-codesign-certs@v3
with:
p12-file-base64: ${{ secrets.IOS_CERT_P12_B64 }}
p12-password: ${{ secrets.IOS_CERT_PASSWORD }}
- name: Download provisioning profile
uses: apple-actions/download-provisioning-profiles@v3
with:
bundle-id: com.mycompany.myapp
profile-type: 'IOS_APP_STORE'
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 \
-c Release \
-f net10.0-ios \
/p:ArchiveOnBuild=true \
/p:RuntimeIdentifier=ios-arm64 \
/p:CodesignKey="Apple Distribution" \
/p:CodesignProvision="MyApp AppStore" \
/p:ApplicationVersion=${{ github.run_number }}
- uses: actions/upload-artifact@v4
with:
name: ios-ipa
path: '**/bin/Release/net10.0-ios/ios-arm64/publish/*.ipa'
O CodesignProvision deve corresponder exatamente ao nome do provisioning profile no App Store Connect, sem o UUID e sem prefixos. Se o build reclama de "No valid provisioning profile found", 99% das vezes é o nome errado, não certificado errado. Rode security find-identity -p codesigning no runner (adicione um step de debug) para listar identidades disponíveis. A action oficial apple-actions/import-codesign-certs cuida do keychain automaticamente e destrói o keychain temporário ao final do job.
Versionamento automático e bump de build number
Nada bloqueia mais releases do que subir para o TestFlight e receber ERROR ITMS-90062: Invalid Value. The value for the bundle version must be a higher number than the previously uploaded version. A solução é derivar a versão do build do próprio pipeline, e nunca versionar manualmente no .csproj. Existem duas estratégias que uso:
Estratégia 1: github.run_number como build number
É a mais simples. Cada execução do workflow incrementa github.run_number, e você injeta como ApplicationVersion. Para ApplicationDisplayVersion (a versão user-facing "1.2.3"), extraia da tag Git:
- name: Compute versions
id: ver
run: |
TAG=${GITHUB_REF#refs/tags/v}
if [[ ! "$TAG" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
TAG="0.0.${{ github.run_number }}"
fi
echo "display=$TAG" >> $GITHUB_OUTPUT
echo "build=${{ github.run_number }}" >> $GITHUB_OUTPUT
- name: Publish
run: |
dotnet publish ... \
/p:ApplicationVersion=${{ steps.ver.outputs.build }} \
/p:ApplicationDisplayVersion=${{ steps.ver.outputs.display }}
Estratégia 2: GitVersion para semver semântico
Se você já usa Conventional Commits, o GitVersion calcula a versão a partir do histórico automaticamente. Adicione um step gittools/actions/gitversion/execute@v3, e use ${{ env.GitVersion_MajorMinorPatch }}. Prefiro essa estratégia em monorepos com múltiplos apps, onde cada app tem sua própria trajetória de versionamento.
Como gerenciar secrets e certificados com segurança
A regra número um: nunca comitar um .p12, .mobileprovision, .jks, ou qualquer credencial da App Store Connect. Já vi equipes vazarem certificados de distribuição num commit "temporário" e passarem semanas para revogar tudo. A abordagem correta usa três camadas:
- GitHub Encrypted Secrets no nível do repositório para tudo que muda pouco (keystore Android, senha do keystore, API key da App Store).
- GitHub Environments para separar staging vs production com regras de aprovação. Crie um environment
productionque exija revisão manual antes de rodar jobs de release. - OpenID Connect (OIDC) para autenticar em Azure/AWS sem armazenar credenciais de longa duração. Se você usa .NET Aspire para o backend, provavelmente já tem infraestrutura Azure e OIDC é o caminho mais seguro.
Para gerar a API key da App Store Connect, vá em Users and Access → Integrations → App Store Connect API, crie uma key com role "App Manager", baixe o .p8 (você só pode baixar uma vez), e armazene como três secrets separados: APPSTORE_ISSUER_ID (UUID), APPSTORE_KEY_ID (10 caracteres alfanuméricos), e APPSTORE_PRIVATE_KEY (o conteúdo completo do .p8 incluindo as linhas -----BEGIN PRIVATE KEY-----).
Distribuição para TestFlight, Google Play e Firebase
Depois que os artefatos assinados estão em upload-artifact, o próximo passo é subir para as lojas. O App Center foi oficialmente descontinuado em 31 de março de 2025, então esqueça-o completamente. As opções atuais são TestFlight (iOS), Google Play Internal Testing (Android), e Firebase App Distribution (ambos, para builds internos rápidos).
| Aspecto | TestFlight | Google Play Internal | Firebase App Distribution |
|---|---|---|---|
| Plataformas | iOS, macCatalyst | Android | iOS + Android |
| Tempo até build ficar disponível | 10–30 min (processamento) | 5–10 min | 1–2 min |
| Limite de testers | 10.000 externos | 100 internos | Sem limite prático |
| Distribuição por email | Sim, mas exige App Store Connect | Sim, via Google Groups | Sim, sem conta Google necessária |
| Automação via GitHub Actions | upload-testflight-build, fastlane pilot | r0adkll/upload-google-play | wzieba/Firebase-Distribution-Github-Action |
| Custo | Incluído no dev program | Incluído no Play Console | Free até 50k testers |
Para upload no TestFlight, adicione depois do build iOS:
- name: Upload to TestFlight
uses: apple-actions/upload-testflight-build@v3
with:
app-path: ${{ steps.publish.outputs.ipa-path }}
issuer-id: ${{ secrets.APPSTORE_ISSUER_ID }}
api-key-id: ${{ secrets.APPSTORE_KEY_ID }}
api-private-key: ${{ secrets.APPSTORE_PRIVATE_KEY }}
Para Google Play, você precisa de uma service account JSON com role Release Manager:
- name: Upload to Google Play
uses: r0adkll/upload-google-play@v1
with:
serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT }}
packageName: com.mycompany.myapp
releaseFiles: '**/*.aab'
track: internal
status: completed
Como reduzir o tempo do pipeline com cache e matriz
Um pipeline .NET MAUI sem otimização leva 12–18 minutos por plataforma. Com cache correto, cai para 3–5 minutos. Três otimizações valem o esforço:
- Cache do
~/.nuget/packages: chave baseada no hash de todos os.csproj, com fallback para chave parcial. Isso economiza 60–90 segundos por job. - Cache do workload MAUI: o diretório
~/.dotnet/sdk-manifestscontém os manifests de workload. Restaure-o em cache separado, e puledotnet workload installse o cache bater (economiza 2–3 min). - Matriz para plataformas paralelas: ao invés de sequenciar Android e iOS, rode em paralelo. GitHub Actions cobra por minuto/runner, mas o wall clock cai pela metade, o que importa mais para desenvolvedores esperando o CI.
strategy:
matrix:
target:
- { framework: net10.0-android, os: ubuntu-24.04, workload: maui-android }
- { framework: net10.0-ios, os: macos-15, workload: maui-ios }
- { framework: net10.0-maccatalyst, os: macos-15, workload: maui-maccatalyst }
runs-on: ${{ matrix.target.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with: { dotnet-version: '10.0.x' }
- run: dotnet workload install ${{ matrix.target.workload }}
- run: dotnet publish -c Release -f ${{ matrix.target.framework }}
Se você processa muitos assets (imagens, fontes, arquivos de Resources/Raw), considere também mover o build para runners self-hosted em macOS Mini M4 para iOS. Um Mac Mini de $600 amortiza em 2–3 meses comparado com o custo de macos-15 a $0.16/min para uma equipe que faz 20+ builds por dia. Fiz essa migração na minha equipe atual e o ROI apareceu no primeiro trimestre.
Erros comuns em pipelines .NET MAUI e como corrigir
Depois de configurar dezenas desses pipelines, esses são os erros que aparecem com mais frequência e como resolvê-los:
NETSDK1147: To build this project, the following workloads must be installed: maui-ios: você esqueceu odotnet workload installantes dopublish. Adicione o step correspondente à plataforma.error MSB4018: Task "GenerateBundleName" failed unexpectedly: o Xcode instalado no runner não corresponde ao esperado pelo workload. Fixe a versão comxcode-select -s.Could not find any provisioning profiles matching '...': o nome emCodesignProvisionnão bate. Rodels ~/Library/MobileDevice/Provisioning\ Profiles/e valide os nomes reais dos profiles baixados.Keystore was tampered with, or password was incorrect: você fez base64 do keystore em Windows e o CRLF foi incluído. Usebase64 -w 0em Linux ou WSL.ITMS-90189: Redundant Binary Upload: o build number é o mesmo que um upload anterior. Sempre incrementeApplicationVersionautomaticamente por run.- Build passa localmente mas falha no CI: 90% das vezes é diferença de SDK. Fixe com
global.jsone revalide comdotnet --infono início do job.
Se você mantém múltiplas variantes do app (dev, staging, prod), consolide as configurações em Directory.Build.props e use MSBuild conditions ao invés de branches YAML separadas. Um workflow que cresceu para 800 linhas de YAML sempre foi mais fácil de refatorar reduzindo a lógica no MSBuild do que dividindo em vários arquivos.
Perguntas frequentes
Posso buildar apps iOS no GitHub Actions sem um Mac?
Sim, usando runners hospedados macos-15, que são VMs macOS na infraestrutura do GitHub. Não é necessário Mac físico para builds, mas para debugar problemas de assinatura ou provisioning você ainda precisa de acesso ao Apple Developer Portal, que exige uma conta paga ($99/ano).
Qual é a diferença entre ApplicationVersion e ApplicationDisplayVersion no .NET MAUI?
ApplicationVersion é o build number interno (inteiro, deve ser único por upload nas lojas). ApplicationDisplayVersion é a versão user-facing no formato semver (por exemplo, "1.2.3"). No CI, use github.run_number para o primeiro e uma tag Git para o segundo.
GitHub Actions ou Azure DevOps para pipelines .NET MAUI?
Para times pequenos e projetos open-source, GitHub Actions é gratuito até 2.000 minutos/mês e integra melhor com repositórios GitHub. Azure DevOps é preferível quando você já tem infra empresarial em Azure, precisa de agent pools self-hosted em escala, ou usa Azure Test Plans. As duas suportam .NET MAUI com esforço semelhante.
Como faço o rollback de um release enviado por engano ao TestFlight?
Você não pode deletar um build do TestFlight, mas pode expirá-lo em App Store Connect indo em TestFlight → Builds → Expire. Para prevenir uploads acidentais, use GitHub Environments com approval obrigatório para o job upload-testflight, e nunca rode uploads em push automático, apenas em tags v*.
Preciso de fastlane com .NET MAUI se estou usando GitHub Actions?
Não. As apple-actions/* cobrem 95% dos casos (importar certificado, baixar provisioning, upload TestFlight) sem precisar de fastlane. Só considere fastlane se você precisa de features específicas como match para gerenciamento de certificados compartilhados entre equipes ou snapshot para screenshots automatizados.
