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:

  1. Rodar expo prebuild para construir os arquivos de projeto Android e iOS

    1. Para Android:

      1. Fazer build do AAB com ./gradlew bundleRelease

      2. Assinar o arquivo AAB

    2. Para iOS, fazer build do projeto com XCode que já gera o IPA assinado para você

Alguns pré-requisitos:

  1. 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.

  2. 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
A Apple não suporta chaves RSA além de 2048 bits

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
Observe que o segundo comando utiliza a flag -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:

  1. Não vamos manter as pastas android e ios no repositório Git. Podemos criá-las na execução da pipeline.

  2. Vamos controlar a versão programaticamente com o número do build da pipeline Azure, utilizando variáveis de ambiente na hora do build.

  3. No link, a pipeline utiliza um único job para fazer a construção do APK Android e do IPA iOS. Vamos separar o build em 2 stages, 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!
    }
}
A configuração customizada foi uma ideia desse outro ótimo artigo: Environment Variables in React Native: The Right Way! (elazizi.com)

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!