React Native Build Pipeline - mit GitHub-Actions und EAS

Eine How-to Anweisung für eine automatisierte Build-Pipeline mit Codebeispielen, Tipps und Semantic Versioning.

6 Minuten
Hero image

Ausgangssituation

In meinem aktuellen Projekt muss ich eine native iOS-App entwickeln. Ich habe mich für React Native mit Expo als Tech-Stack entschieden. Dank meiner Erfahrung mit TypeScript, React und Redux Stores kann ich sofort loslegen. Außerdem kann ich bei Bedarf problemlos Android-Apps erstellen.

Der Schwerpunkt dieses Artikels liegt auf der Automatisierung der Build-Pipeline. Als Code-Repository verwende ich GitHub und damit GitHub-Actions für die Build-Pipeline. Ich muss die Anwendung über das MDM-System (Mobile Device Management) meines Kunden verteilen. Daher benötige ich ein versioniertes Build-Artefakt, das ich in das MDM-System hochladen kann.

Voraussetzung ist die Einrichtung des iOS-Zertifikats und die Verwendung der Apple-Dev-Tools. Auch dies kann automatisiert werden, ich möchte mich jedoch auf den Build selbst konzentrieren.

Zielsetzung

Vor der Automatisierung muss ich meine Ziele definieren. Los geht's:

  • Für jeden Push im Main-Branch eine neue Version erstellen.
  • Semantische Versionierung zur Bestimmung der neuen App-Version verwenden.
  • Eine versionierte IPA-Datei als Build-Artefakt erzeugen.

Lösung

Schritt 1: Continuous Integration

Ich habe in meinem Repository eine ci.yml, die auf allen Branches ausgeführt wird, sodass die Qualitätsprüfungen auch die Feature-Zweige abdecken.

name: CI
on:
  push:
    branches:
      - "**"

Qualitätsprüfungen

Vor der App-Entwicklung habe ich einige Schritte zur Qualitätssicherung durchgeführt:

  • Auditierung der NPM-Pakete mit better-npm-audit. Dieser Wrapper um das standardmäßige npm audit ermöglicht es mir bei Bedarf, Schwachstellen zu ignorieren, indem ich dies in der Datei .nsprc konfiguriere.
  • Rechtschreibprüfung mit cspell. Tippfehler, z. B. in Variablennamen, führen oft zu schwerwiegenden Fehlern. Ich kann neben Englisch mehrere Sprachen konfigurieren, falls mein Code unter anderem deutsche Textpassagen enthält. Zusätzlich kann ich ein Wörterbuch mit bekannten Wörtern als cspell-words.txt hinzufügen.
  • Lint-Prüfung des Codes mithilfe meiner Lint-Regeln, einschließlich prettier, die für jedes gute Projekt obligatorisch sind. Dies vermeidet Diskussionen über die Code-Formatierung. Der Standard wird einfach angewendet.
  • TypeScript-Prüfung. Ich verwende TypeScript für Autovervollständigung, einfacheres Refactoring und als ein Sicherheitsnetz zur Compile-Time.
  • (Unit-)Tests stellen sicher, dass alles wie erwartet funktioniert. Dies ist Teil meiner testgetriebenen Philosophie und hilft beim Refactoring, Nebeneffekte zu erkennen.
jobs:
  initial_checks:
    name: Verify build and prepare cache
    runs-on: ubuntu-latest
    timeout-minutes: 3
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Read .nvmrc
        run: echo "NODE_VERSION=$(cat .nvmrc)" >> $GITHUB_OUTPUT
        id: nvm

      - name: Setup Node
        uses: actions/setup-node@v4
        with:
          node-version: ${{ steps.nvm.outputs.NODE_VERSION }}

      - name: Cache dependencies
        id: cache
        uses: actions/cache@v4
        with:
          path: |
            ./node_modules
            !node_modules/.cache
          # The hashFiles function generates a unique key for the cache based on the contents of the package-lock.json file.
          # This ensures that the cache is only invalidated when the dependencies of the project change.
          key: ${{ runner.os }}-node-modules-${{ hashFiles('package-lock.json') }}
          # 'restore-keys' are fallback keys used if a cache miss occurs on the 'key' field. They provide a way to restore
          # a cache from a previous state that doesn't match the 'key' exactly. They are checked sequentially until a cache hit is found.
          # A list of restore keys is useful when restoring a cache from another branch because restore keys can partially match cache keys.
          restore-keys: |
            ${{ runner.os }}-node-
            ${{ runner.os }}-

      - name: Install dependencies
        if: steps.cache.outputs.cache-hit != 'true'
        run: npm ci --ignore-scripts

      - name: NPM Audit
        run: npm run audit

      - name: Run spell check
        run: npm run cspell:lint

      - name: Run ESLint
        run: npm run lint

      - name: Run Typescript checks
        run: npm run ts

      - name: Run Tests
        env:
          NODE_OPTIONS: "--max_old_space_size=8192"
        run: npm run test -- --ci --maxWorkers=2

Tagging

In meine Main-Branch tagge ich meinen Code gemäß den Zielen mit einem semantischen Release-Ansatz. Für eine React Native + Expo-Anwendung muss ich sicherstellen, dass alle relevanten Dateien aktualisiert werden. Dazu gehören:

  • package.json und die zugehörige Sperrdatei mit der neuesten Version.
  • app.json aus der Expo → Die korrekte Versionsnummer im EAS-Build ist unerlässlich!

Dazu muss ich diese Dateien in git commit und git push meiner semantischen Versionskonfiguration auflisten. Daher muss .releaserc wie folgt konfiguriert werden:

{
  "tagFormat": "${version}",
  "branches": ["main"],
  "plugins": [
    "@semantic-release/commit-analyzer",
    "@semantic-release/release-notes-generator",
    "@semantic-release/github",
    [
      "@semantic-release/npm",
      { "npmPublish": false }
    ],
    [
      "semantic-release-expo",
      {
        "manifests": ["app.json"]
      }
    ],
    [
      "@semantic-release/git",
      {
        "assets": [
          "package.json",
          "package-lock.json",
          "app.json"
        ],
        "message": "chore(release): ${nextRelease.version} [skip ci]"
      }
    ]
  ]
}

Hinweis: Um sicherzustellen, dass die semantische Freigabe funktioniert, stellst du sicher, dass die Commit-Nachrichten die Syntaxregeln einhalten. Dies kann durch Git-Hooks und den entsprechenden NPM-Paket @semantic-release/commit-analyzer erreicht werden.

Schritt 2: Release

In meinem Repository habe ich eine release.yml, die bei allen neu erstellten Tags ausgeführt wird, sodass mein Artefakt erstellt wird.

name: Release
on:
  push:
    tags:
      - "**"
  release:
    types:
      - created

Ich triggere diesen Flow für jeden von ci.yml geschriebenen Tag. Im Rahmen dieses Flows triggere ich den Build auf EAS. EAS Build ist ein gehosteter Dienst zum Erstellen von App-Binärdateien für Expo- und React-Native-Projekte.

Die GitHub-Action ist abgeschlossen, sobald der Auftrag in die EAS-Build-Warteschlange gestellt wurde. Die Erstellung der Anwendung durch EAS selbst kann mehrere Minuten bis zu einer Stunde dauern – diese Zeit wird nicht zu den Build-Minuten meiner GitHub-Action hinzugerechnet! So spare ich durch Automatisierung viel Zeit, ohne die Kosten zu erhöhen.

jobs:
  build:
    name: Install and build
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Read .nvmrc
        run: echo "NODE_VERSION=$(cat .nvmrc)" >> $GITHUB_OUTPUT
        id: nvm

      - name: Setup node
        uses: actions/setup-node@v4
        with:
          node-version: ${{ steps.nvm.outputs.NODE_VERSION }}
          cache: npm

      - name: Setup Expo and EAS
        uses: expo/expo-github-action@v8
        with:
          eas-version: latest
          token: ${{ secrets.EXPO_TOKEN }}

      - name: Install dependencies
        run: npm ci

      - name: Build on EAS
        run: eas build --platform ios --profile production --non-interactive --no-wait

Fazit

Mithilfe von GitHub-Actions und dem Expo-Tool EAS wird eine zuverlässige und zeitsparende Automatisierung eingerichtet, die die Ziele erfüllt, für jeden Push auf den Main-Branch eine neue semantische Version mit einem IPA-Artefakt als Ausgabe zu erstellen.

call to action background image

Abonniere meinen Newsletter

Erhalte einmal im Monat Nachrichten aus den Bereichen Softwareentwicklung und Kommunikation gespikt mit Buch- und Linkempfehlungen.