Azure Pipelines com Expo Prebuild
Vou contar um pouquinho como montamos uma pipeline para fazer build dos AABs e IPAs do nosso aplicativo react-native com Expo no Azure DevOps.
Incrivelmente, foi um processo com menos documentação e exemplos do que eu esperava. Esbarrei em alguns problemas, por ser a primeira vez montando algo do tipo e acho que por isso quero compartilhar, para talvez economizar o sofrimento de outras pessoas hehe
Um aviso: se você ainda não tem uma conta de desenvolvedor na Apple, não vai conseguir fazer o build da pipeline, visto que precisa de arquivos que só podem ser gerados dentro do painel do desenvolvedor da Apple.
Para Android, você deve conseguir fazer o build e instalar no seu dispositivo sem problemas mesmo sem uma conta de desenvolvedor Google Play.
Antes, uma breve reflexão...
Alguns serviços de tecnologia são realmente ótimos quando você utiliza tudo que eles oferecem. Mas muitas vezes isso custa.
Quando decidimos utilizar o Expo no nosso aplicativo, sabíamos que faríamos muita coisa por ele, mas sabíamos que algumas outras faríamos por fora. Na hora da decisão, a gente pensou que isso seria sobre utilizar qualquer implementação ou plugin que precisasse de alguma customização na aplicação nativa, mas na verdade acabou que precisamos muito antes de documentação sobre coisas fora do Expo: o build dos pacotes do aplicativo.
A princípio, parece um processo bem direto: você faz prebuild
e então segue o processo normal de um aplicativo react-native
. Bom, não foi tão rápido assim...
Processo de build básico
Para fazer a construção de um pacote de aplicativo com Expo, você deve rodar os seguintes passos:
Rodar
expo prebuild
para construir os arquivos de projeto Android e iOSPara Android:
Fazer build do
AAB
com./gradlew bundleRelease
Assinar o arquivo
AAB
Para iOS, fazer build do projeto com XCode que já gera o
IPA
assinado para você
Alguns pré-requisitos:
No Android, você precisa gerar ter uma chave assinar o pacote. Apesar do comando
bundleRelease
já gerar assinar para você, para deixar o processo mais controlado, melhor você gerar a sua própria para a pipeline.No iOS, você precisa gerar o certificado e o provisioning profile.
Gerando os arquivos para assinatura
Android
Para gerar sua chave e certificado de assinatura, utilize o comando:
keytool -genkeypair -v -storetype PKCS12 -keystore android.jks -alias app -keyalg RSA -keysize 4096 -validity 10000
Durante o processo, será solicitada uma senha para o arquivo. Guarde essa senha, pois ela será necessária depois.
iOS
Aqui, vamos precisar de um pouco mais de trabalho rs
Você vai precisar gerar pelo menos um arquivo P12
. Esse arquivo contém um certificado e uma chave privada. Para gerar esse arquivo, você vai precisar pedir um certificado com um arquivo CSR
. Para gerar o CSR
, utilize o script bash abaixo:
openssl genrsa -out app-ios.key 2048
openssl req -new -key app-ios.key -out app-ios.csr
O script deve te pedir algumas informações para serem incluídas no certificado (Distinguished names). Preencha de acordo com o que for solicitado na linha de comando.
Com o arquivo CSR
gerado, vá na página de certificados dentro do painel do desenvolvedor da Apple e crie um certificado adequado para sua aplicação. No nosso caso, utilizamos certificados separados para assinar uma build de desenvolvimento (certificado do tipo iOS App Development
) e outra para produção (certificado do tipo iOS Distribution
)
Depois de seguir o passo a passo, você vai poder baixar o arquivo CER
. Salve ele como app-ios.cer
na mesma pasta que estavam os arquivos CSR
e KEY
criados no script acima e use o script abaixo para gerar o arquivo P12
openssl x509 -in app-ios.cer -inform DER -out app-ios.pem -outform PEM
openssl pkcs12 -export -legacy -inkey mdr-app-ios.key -in mdr-app-ios.pem -out mdr-app-ios.p12
-legacy
. Isso porque utilizei o openssl
3.1.1 via Git Bash e o Mac utiliza o openssl 1.x
. Sem essa flag, o arquivo não poderá ser utilizado para assinar mais a frente!Guarde também a senha utilizada na geração do arquivo P12
. Ela será necessária mais a frente.
Agora precisamos gerar um Provisioning Profile
. Basta entrar na página dos profiles dentro do painel do desenvolvedor da Apple e criar um. No nosso caso, geramos 1 para desenvolvimento (tipo iOS App Development
) e um para produção (tipo App Store
)
Logo depois dessa tela, você deverá selecionar as capabilities
do seu aplicativo. No nosso projeto, foi necessário selecionar o Push Notification
. Se você gerar um profile
sem as capabilities
necessárias, você irá tomar um erro na etapa de geração do IPA que não vai te dizer bem que esse é o problema. Para ver quais são as necessárias, veja via XCode no projeto.
Concluído o processo, você poderá baixar um arquivo mobileprovision
.
Todos esses arquivos devem ser enviados para a pipeline do Azure como arquivos seguros, no menu Pipelines > Library
, na aba Secure Files
.
Aproveite que está aqui e crie um Variable Group
e salve as senhas utilizadas para o certificado android e arquivo p12
iOS. Vamos considerar que o nome desse grupo é app-vg
e que ele tem as propriedades androidKeyStorePassword
e iosP12Password
.
LogRocket: uma fonte de alívio
Quando começamos a construção, seguimos o modelo proposto no blog da LogRocket para react-native
: Continuous deployment of React Native app with Azure DevOps - LogRocket Blog
Lendo o artigo, de cara percebemos alguns ajustes a serem feitos:
Não vamos manter as pastas
android
eios
no repositório Git. Podemos criá-las na execução da pipeline.Vamos controlar a versão programaticamente com o número do build da pipeline Azure, utilizando variáveis de ambiente na hora do build.
No link, a pipeline utiliza um único job para fazer a construção do
APK
Android e doIPA
iOS. Vamos separar o build em 2stages
, um para cada OS.
Como no link, nós também estamos utilizando o App Center
para fazer publicação das versões, mas essa é uma etapa opcional que não vou entrar em detalhes aqui.
Configurando a versão da aplicação
O formato de versão que vamos utilizar é o recomendado pela Apple, com 3 inteiros, separados por ponto.
No arquivo app.json
, vamos deixar os dois primeiros números e o terceiro será concatenado com uma variável de ambiente chamada BUILD_NUMBER
. Para evitar erros durante o desenvolvimento, vamos deixar o valor padrão 1 caso essa variável esteja vazia.
Crie um arquivo app.config.ts
na raiz da sua aplicação com o seguinte código
import type { ConfigContext, ExpoConfig } from '@expo/config'
export default function ConfigExpo({ config }: ConfigContext): ExpoConfig {
if (!config.version) {
throw new Error('No version configured in app.json.')
}
const buildNumber = parseInt(process.env.BUILD_NUMBER ?? '1')
const version = `${config.version}.${buildNumber}`
return {
...config,
version,
android: {
...config.android,
versionCode: buildNumber
},
ios: {
...config.ios,
buildNumber: buildNumber.toString()
},
name: config.name!,
slug: config.slug!
}
}
Agora sim, a pipeline!
Nós gostamos muito de usar templates de pipeline aqui na Mdr. Ajuda muito a reaproveitar declarações. Como temos build para desenvolvimento e produção, sem essa funcionalidade ficaria muito código duplicado na nossa definição de pipeline rs
Para facilitar o exemplo, vou fazer como se fosse apenas um ambiente. Não se esqueça de ajustar o nome dos arquivos de acordo com o que você colocou no Secure Files
!
Ah, os exemplos abaixo estão com pnpm
. Se você utiliza yarn
ou npm
, faça os ajustes necessários no template de prebuild
.
Lembrando que para usar o pnpm
, você deve ter o arquivo .npmrc
na raíz do seu projeto Expo com o seguinte conteúdo:
engine-strict=true
node-linker=hoisted
Arquivo .azuredevops/prebuild.yml
parameters:
- name: buildNumber
type: string
- name: platform
type: string
steps:
- task: UseNode@1
inputs:
version: '20.10.0'
- script: corepack enable && corepack prepare pnpm@latest-8 --activate
displayName: Setup pnpm
- bash: pnpm i --frozen-lockfile
displayName: Install dependencies
- bash: npx expo prebuild --platform ${{ parameters.platform }}
displayName: Expo PreBuild
env:
BUILD_NUMBER: ${{ parameters.buildNumber }}
Arquivo .azuredevops/build-android.yml
parameters:
- name: keyStoreFile
type: string
- name: variableGroup
type: string
jobs:
- job: BuildAndroid
displayName: Build Android App Bundle
variables:
- group: ${{ parameters.variableGroup }}
steps:
# Use o Java 17, porque o Expo usa Gradle 8.0, sem suporte ao Java 20 ainda
- task: JavaToolInstaller@0
displayName: Install Java 17
inputs:
jdkArchitectureOption: x64
jdkSourceOption: "PreInstalled"
versionSpec: "17"
- template: prebuild.yml
parameters:
buildNumber: $(Build.BuildId)
platform: android
- bash: ./gradlew bundleRelease
displayName: Build Unsigned App Bundle
workingDirectory: android/
env:
BUILD_NUMBER: $(Build.BuildId)
# Precisamos remover a assinatura antiga, ou o Google Play vai
# reclamar da dupla assinatura ao submeter a versão
- bash: zip -d app-release.aab 'META-INF/*.SF' 'META-INF/*.RSA'
displayName: Remove debug signature from App Bundle
workingDirectory: android/app/build/outputs/bundle/release/
# Importante especificar aqui os 'arguments'. Por padrão, essa task
# faz a assinatura com SHA1 e isso falha na hora de submeter o aplicativo
# na Google Play. Esse foi um dos problemas que mais demoramos para descobrir rs
- task: AndroidSigning@2
displayName: Sign App Bundle
inputs:
apkFiles: android/app/build/outputs/bundle/release/app-release.aab
jarsignerKeyPassword: $(androidKeyStorePassword)
jarsignerKeystoreFile: ${{ parameters.keyStoreFile }}
jarsignerKeystorePassword: $(androidKeyStorePassword)
jarsignerKeystoreAlias: app
jarsignerArguments: '-verbose -sigalg SHA256withRSA -digestalg SHA-256'
zipalign: true
- task: PublishBuildArtifacts@1
displayName: Publish App Bundle to Pipeline
inputs:
ArtifactName: android
PathtoPublish: android/app/build/outputs/bundle/release/
publishLocation: Container
Arquivo .azuredevops/build-ios.yml
parameters:
- name: appCenterAppSlug
type: string
- name: appCenterServiceConnection
type: string
- name: environmentPrefix
type: string
- name: iosCertificateFile
type: string
- name: iosProvisioningProfileFile
type: string
- name: variableGroup
type: string
jobs:
- job: BuildIos
displayName: Build iOS IPA
pool:
vmImage: macos-latest
variables:
- group: ${{ parameters.variableGroup }}
steps:
- task: InstallAppleCertificate@2
displayName: Install Apple Certificate
inputs:
certSecureFile: ${{ parameters.iosCertificateFile }}
certPwd: $(iosP12Password)
keychain: temp
- task: InstallAppleProvisioningProfile@1
displayName: Install Apple Provisioning Profile
inputs:
provisioningProfileLocation: secureFiles
provProfileSecureFile: ${{ parameters.iosProvisioningProfileFile }}
removeProfile: true
- template: prebuild.yml
parameters:
buildNumber: $(Build.BuildId)
environmentPrefix: ${{ parameters.environmentPrefix }}
platform: ios
# As variáveis APPLE_CERTIFICATE_SIGNING_IDENTITY and APPLE_PROV_PROFILE_UUID
# são definidas pelas tasks anteriores InstallAppleCertificate & InstallAppleProvisioningProfile
# Outro ponto de atenção aqui: tanto o scheme quando o nome do arquivo xcworkspace
# refletem os valores especificados no app.json. Vale fazer um build local para ver qual
# o nome gerado para o seu projeto
- task: Xcode@5
displayName: Build IPA
inputs:
actions: build
configuration: Release
exportPath: output
packageApp: true
provisioningProfileUuid: $(APPLE_PROV_PROFILE_UUID)
scheme: <seu_scheme>
sdk: iphoneos
signingOption: manual
signingIdentity: $(APPLE_CERTIFICATE_SIGNING_IDENTITY)
xcWorkspacePath: ios/<nome_do_app>.xcworkspace
env:
BUILD_NUMBER: $(Build.BuildId)
# Como não sei exatamente onde o IPA é construído, por não ter um
# mac para fazer o teste local, eu jogo todos os arquivos IPA
# para a pasta de staging do agente que está executando a pipeline
- task: CopyFiles@2
displayName: Copy IPA
inputs:
contents: '**/*.ipa'
flattenFolders: true
overWrite: true
targetFolder: $(build.artifactStagingDirectory)
# Ao invés de enviar só o IPA, mando a pasta toda. Assim, não preciso saber
# exatamente onde o IPA está.
- task: PublishBuildArtifacts@1
displayName: Publish IPA to Pipeline
inputs:
PathtoPublish: $(build.artifactStagingDirectory)
ArtifactName: ios
publishLocation: Container
Arquivo azure-pipelines.yml
pool:
vmImage: 'ubuntu-22.04'
stages:
- stage: BuildAndroidApp
displayName: Build Android App Bundle
dependsOn: []
jobs:
- template: .azuredevops/build-android.yml
parameters:
keyStoreFile: app-android.jks
variableGroup: app-vg
- stage: BuildIOSApp
displayName: Build iOS IPA
dependsOn: []
jobs:
- template: .azuredevops/build-ios.yml
parameters:
iosCertificateFile: app-ios.p12
iosProvisioningProfileFile: app-ios.mobileprovision
variableGroup: app-vg
E é isso! Espero ter ajudado alguém com esse exemplo. Sem dúvida foi muito difícil chegar nele (a etapa de assinatura do Android me deixou bem maluco rs)
Esses arquivos são alterações dos nossos originais e foram feitos para este artigo sem validar a sua execução. Caso você tenha algum erro montando a sua pipeline a partir desse exemplo, fique a vontade para entrar em contato comigo (marcos.romero@mercadoderecebiveis.com.br
) que ficarei feliz em ajudar a corrigir!