GitHub Action Funktionen mit GitHub Actions überprüfen

23.12.2020, von Christian Glahn

Im DxI-Team verwenden wir git und GitHub für unsere Forschung und Lehre. Vor etwas mehr als einem Jahr habe ich GitHub Actions entdeckt und nach und nach in unsere Projekte eingebunden. Mit GitHub Actions können wir standardisierte Arbeitsprozesse in unseren GitHub Projekten automatisieren. In den letzten Tagen habe ich viel über die Funktionsweise von GitHub Actions gelernt, die so nicht in der Dokumentation stehen.

Die Automatisierung mit GitHub Actions deshalb besonders interessant, weil man diese Aktionen mit eigenen Funktionen erweitern kann. Natürlich müssen wir solche Funktionserweiterungen nicht selbst programmieren, sondern können sie auch von anderen übernehmen. Dazu gibt es die GitHub Actions Marketplace. Dort habe ich inzwischen zwei Aufgaben, die mir die Wartung meiner JavaScript Programmen erleichtern:

  • Mit Autotagger erzeuge ich automatisch Versionsnummern, wenn sich etwas an meinem Code ändert.
  • Release Check prüft, ob in Projekt funktionale Änderungen durchgeführt wurden, die eine neue Version notwendig machen.

Weil ich nicht endlos viel Zeit habe, um mich um die Pflege meiner Programme zu kümmern, habe ich diesen Prozess automatisiert. Das macht deshalb Sinn, weil die meisten Änderungen wegen Sicherheitsupdates in den von mir verwendeten Bibliotheken notwendig werden. Das bedeutet, dass sich an meinem Code eigentlich nichts ändert. Genau das wollte ich mit meinen GitHub Actions Funktionen auch machen. Das ist aber komplizierter als bei normalen JavaScript Packeten, weil auch die besondere Laufzeitumgebung von GitHub Actions mitberücksichtigt werden muss.

GitHub Actions testen

Mein normaler Arbeitsprozess zur Kontrolle der Qualität meines Codes läuft wie folgt an: Zuerst überprüfe ich, ob mein Code allen stilistischen Anforderungen genügt. Diesen Prozess bezeichnet man als linten und verhindert, dass ich aus lauter Eile nicht wartbaren Spaghetti-Code veröffentliche. Für das Linten von JavaScript verwende ich eslint. Stilistisch unschöner Code deutet oft auf tiefer liegende Probleme hin, so dass ich erst alle Mängel auf dieser Ebene behebe, bevor ich weitergehe.

Der zweite Schritt ist das Unit-Testen, auf das ich im nächsten Teil genauer eingehe.

Abschliessend prüfe ich, ob das Programm neu veröffentlicht werden muss und falls das nötig ist, veröffentliche ich meine Code auf den entsprechenden Portalen. Dieser Schritt ist recht Komplex, weil Teile der Dokumentation angepasst werden müssen, neue Versionsnummer werden gebraucht und der Code muss für Produktionsumgebungen aufbereitet werden. Manchmal gibt es aber kleine Änderungen, die mir bei der Entwicklung helfen aber eigentlich nichts mit dem eigentlichen Programm zu tun haben. Bei solchen Änderungen muss natürlich nichts veröffentlicht werden. Bis vor Kurzem habe ich diesen Schritt in einem Zug ausgeführt. Bei komplexeren Anwendungen, wie z.B. bei Funktionen für GitHub Actions, ist es aber notwendig das Vorbereiten (bzw. “Bauen”) und das eigentliche Veröffentlichen voneinander zutrennen. Dazu aber weiter unten im Abschnitt “Laufzeittests” mehr.

Unit Tests

Ich verwende Unit-Tests, um die korrekte Arbeitsweise meiner Funktionen strukturiert zu überprüfen. Solche Tests sind kleine Funktionen, die Teile meines Codes unter kontrollierten Bedingungen aufrufen und überprüfen, ob der erzeugte Effekt meinen Erwartungen entspricht. Mein Code ist nur dann veröffentlichbar, wenn keiner dieser Tests fehlschlägt. Zum Testen verwende ich die Frameworks mocha und chai. Meine Tests habe ich in der “Packetinformation” meiner Funktion in der Datei packet.json verlinkt, so dass ich auf meinem Computer nur npm test aufrufen muss, um meine Funktion zu starten.

Normalerweise würde ich das zum Testen eines normalen JavaScript Programms auch machen. Unit Tests von GitHub Actions sind aber komplizierter. Ein Grund dafür ist, dass GitHub Actions eine recht komplexe Laufzeitumgebung bereitstellt, die praktisch magisch in den Erweiterungen bereitgestellt wird. Dazu gehören sog. Geheimnisse (bzw. Secrets) aber auch der Kontext einer Aktivität. Entsprechend haben die wenigsten der offiziellen Erweiterungen für GitHub Actions gar keine Funktionsüberprüfung. Das ist insbesondere bei @actions/github-script der Fall.

Für meine Funktionen brauche ich ein sog. GITHUB_TOKEN, dass in einer Aktion immer vorhanden ist. Anwender meiner Aktionen sehen dieses Token in der Regel nicht, weil ich für meine Funktionen festgelegt habe, dass es das Aktionstoken standardmässig verwendet werden soll. Die Einstellung findet sich dafür in der action.yml-Datei:


inputs:
  github-token:
    description: "the token"
    default: ${{ github.token }}
    required: false

Mit dieser Einstellung kann meine GitHub Actions Funktion auf die GitHub-Dienste im Kontext der aufrufenden Aktion zugreifen, ohne dass man das bei der Definition der Automatisierungsregeln besonders berücksichtigen muss. Es funktioniert einfach :)

In Unit-Tests kann ich auf diese Voreinstellungen aber nicht zugreifen. Ich muss also ein alternatives Token an meinen Unit Test übergeben, damit meine Tests die korrekte Funktionsweise überprüfen können. Ein solches Token erzeugt man sich in den Entwickler Einstellungen auf der GitHub Seite unter Personal Access Token. Dieses Token speichere ich in meinem Passwort-Manager. Anschliessen übergebe ich das Token meinen Tests über eine Umgebungsvariable, so dass ich auf meinem Computer meine Unit-Tests mit dem folgenden Kommando ausführen kann:

> GITHUB_TOKEN=Mein_Personal_Access_Token_Von_GitHub npm test

Natürlich muss Mein_Personal_Access_Token_Von_GitHub durch das richtige Token ersetzt werden.

In der Praxis will ich aber nicht immer auf meinem Computer testen. Es kommt häufiger vor, dass ich eine kleine Änderung am Code vornehme und gar nicht mehr auf meinem Rechner teste, sondern diese Aufgabe GitHub überlasse. Um diesen Schritt zu automatisieren muss ich das Token einer Aktion ebenfalls als Umgebungsvariable meinen Tests übergeben. In der Prozessspezifikation sieht das dann wie folgt aus:


    - name: Tests ausführen
      run: npm test
      env:
        CI: "true"
        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Der komplette GitHub Action Job sieht dann wie folgt aus:


  test: 
    needs: lint
    runs-on: ubuntu-latest
    steps:
    - name: Aktuellen Code von GitHub laden
      uses: actions/checkout@v2

    - name: Javascript Arbeitsumgebung vorbereiten
      uses: actions/setup-node@v1
      with:
        node-version: 12

    - name: Zusätzliche Bibliotheken installieren
      run: npm ci

    - name: Tests ausführen
      run: npm test
      env:
        CI: "true"
        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Das Testen ist der letzte Schritt. Der Rest ist die Vorbereitung, die ich normalerweise auf meinem Computer ausführen würde, um eine kontrollierte Testumgebung zu erstellen. Bei dieser Spezifikation ist besonders zu beachten, dass sie von einem lint Job abhängig ist. Schlägt das Linten also fehl, test ich gar nicht erst.

Laufzeittests

Unit-Tests geben ein recht gutes Bild über die erwartete Arbeitsweise eines Programms. Dabei müssen wir aber berücksichtige, dass unsere Unit-Tests immer unsere Erwartungen an die Laufzeitumgebung wiederspiegeln. Das ist nicht das gleiche wie die Laufzeitumgebung selbst. Nun passt GitHub regelmässig die Laufzeitumgebung an, so dass die Annahmen, die gestern noch funktioniert haben, heute nicht mehr gültig sein können. Deshalb sind Laufzeittests wichtig, denn sie bestätigen die Ergebnisse der Unit-Tests in der tatsächlichen Arbeitsumgebung.

Die Idee eines Laufzeittests ist immer gleich:

  1. Baue das Programm.
  2. Rufe das Programm in einer realistischen Umgebung mit echten Daten auf.

Die grosse Herausforderung ist hier eine GitHub Action Funktion mit GitHub Actions zu überprüfen. Das klingt einfach, birgt aber seine Tücken, die sich in der Funktionsweise von GitHub Actions verstecken.

In GitHub Actions kann man eine Funktion über das uses-Attribut eines Arbeitsschritts aufrufen. Das haben wir oben schon gesehen. Ein solcher Funktionsaufruf sieht zum Beispiel wie folgt aus: uses: actions/setup-node@v1. In uses steht ein normales GitHub-Repository. Das @ zeigt eine bestimmte Version der Funktion an. Diese “Version” ist nichts anderes als eine normale git-Referenz, also ein Tag oder ein Arbeitszweig (bzw. Branch).

Meine release-check Funktion kann man zum Beispiel wie folgt aufrufen: uses: phish108/release-check@1.0.9. Dieser Aufruf führt die Version 1.0.9 der Funktion aus. Wenn wir einen Laufzeittest ausführen wollen, haben wir aber noch keine neue Version. In solchen Fällen können wir direkt auf den main-Zweig verweisen, um die letzte Entwicklungsversion zu verwenden. Dafür binden wir die Funktion wie folgt ein: uses: phish108/release-check@main.

<p class=”alert alert-warning”markdown=1>Achtung Früher hiess der Hauptzweig eines Repositories auf GitHub master. Im Zuge der politischen Korrektheit wurde dieser Name in 2020 auf main geändert. Diese Änderung betrifft nur neue Projekte.</p>

Mit diesem Wissen haben wir bereits alle Zutaten für einen Laufzeittest:

  1. Wir haben die realisitische Umgebung, in der unsere Funktion arbeiten soll.
  2. Wir können auf die letzte unveröffentlichte Version unserer Funktion zugreifen.
  3. Wir haben die echten Daten unseres GitHub-Projekts.

Das hat bisher immer super funktioniert, aber mit GitHub Actions Funktionen ist das leider nicht so einfach. Dazu muss man wissen wie GitHub Actions funktioniert.

  1. Ein GitHub Action Workflow besteht aus mehreren “Jobs”.
  2. Ein Job ist z.B. das Testen.
  3. Jeder Job eines Workflows arbeitet in einer eigenen Arbeitsumgebung. Wir können nur Abhängigkeiten zwischen Jobs nur über Erfolg oder Miserfolg erstellen. D.h. Klappt ein Job nicht, dann werden die davon abhängigen Jobs nicht ausgeführt.
  4. Alle Jobs arbeiten mit dem gleichen Kontext.
  5. Jeder Job besteht aus mehreren Schritten (steps). Diese Schritte können wir mit komplexen Regeln über das if-Attribut steuern.
  6. Alle Funktionen, die in einem Job verwendet werden, werden geladen bevor der Job gestartet wird.
  7. Ist ein Job von einem anderen Job abhängig, werden die Funktionen für die Arbeitsschritte erst dann geladen, wenn der andere Job erfolgreich abgeschlossen wurde.

Wegen der Bedingung 6 können wir keine neue Version unseres Programms bauen und diese Version als GitHub Action Funktion im gleichen Job aufrufen. Genau das war mein Misverständnis.

ACHTUNG Die folgenden Arbeitsschritte funktionieren nicht, wenn der Workflow die hypothetische Funktion phish108/my-action (oder eine andere Funktion) überprüft!


    - name: Neue Version bauen
      run: | 
        npm ci
        npm run package
        git commit -m "updated package file for $GITSHA" -a 
      env:
        GITSHA: ${{ github.sha }}
        
    - name: Push changes
      uses: ad-m/github-push-action@master
      with:
        github_token: ${{ secrets.GITHUB_TOKEN }}
        branch: ${{ github.ref }}
    
    - name: Laufzeittest - FUNKTIONIERT NICHT
      id: releaseMain
      uses: phish108/my-action@main

Die Ursache ist, dass ich zwar eine neue Version baue und im main-Arbeitszweig erzeuge, aber der Job, in dem diese Schritte laufen, die Funktion phish108/my-action@main bereits geladen hat. Die neu gebaute Version wird daher noch nicht berücksichtigt. Damit mein Laufzeittest trotzdem funktioniert, muss ich das Bauen und das Testen in zwei Jobs trennen. Die beiden Jobs verknüpfe ich über eine Abhängigkeit, so dass mein Laufzeittest nur dann läuft, wenn meine neue Version erfolgreich gebaut wurde.

Build

Im Build Schritt erzeuge ich die lauffähige Funktion, die später als Aktion ausgeführt werden kann. Der entscheidende


    - uses: actions/checkout@v2

    - name: Arbeitsumgebung erstellen
      uses: actions/setup-node@v1
      with:
        node-version: 12
        
    - name: Neue Version bauen
      run: | 
        npm ci
        npm run package
        git commit -m "updated package file for $GITSHA" -a 
      env:
        GITSHA: ${{ github.sha }}
        
    - name: Push changes
      uses: ad-m/github-push-action@master
      with:
        github_token: ${{ secrets.GITHUB_TOKEN }}
        branch: ${{ github.ref }}

Im dritten Schritt (“Neue Version bauen”) läuft ein kleines Shell Script, das meine Funktion für die GitHub Actions Umgebung baut und abschliessend mit git commit in mein Repository läd. Dieses Kommando ist wichtig.

Bei diesem Schritt müssen wir zusätzlich aufpassen, weil dieser Schritt fehlschlägt, wenn es keine Änderung im Funktionscode gab. Das können wir aber leicht mit meiner release-check-Funktion abfangen.

Der letzte Arbeitsschritt mit uses: ad-m/github-push-action@master bringt diese automatisch erstellte Änderung aus meiner Arbeitsumgebung in mein Repository auf GitHub.

Auführen

Der Laufzeittest kann nun in einem eigenen Job laufen. Der Test ist in meinem Fall ganz einfach, weil ich nur überprüfen muss, ob der jeweilige Arbeitsschritt erfolgreich abgeschlossen wird.

    - name: check changes MAIN
      id: releaseMain
      uses: phish108/release-check@main

Hat sich nun etwas in der Laufzeit geändert, wird dieser Schritt fehlschlagen, weil mein Code dann fehlerhaft ist.

Für komplexere Aufgaben müssen wir noch zusätzliche Überprüfungen erstellen, damit wir ganz sicher sein können, dass alles automatisch so abläuft, wie wir es uns erwarten.

Finalisieren und veröffentlichen

Das letzte Problem gab es bei mir dann beim Veröffentlichen, denn dazu müssen noch zusätzliche Änderungen in der Dokumentation und in der Installationsdatei vorgenommen werden.

Das Hauptproblem bei diesem Schritt ist, dass es in einem eigenen Job läuft. Dieser Job arbeitet aber mit dem gleichen Kontext wie alle anderen Jobs im gleichen Workflow. D.h. die checkout-Funktion holt nicht die letzte Version des main-Zweigs, sondern die Version, mit der der Workflow gestartet wurde. Wir müssen also der checkout-Funktion explizit mitteilen, dass sie den main-Zweig verwenden muss, weil sonst unsere Änderungen aus dem Build-Job fehlen und wir dann Fehlermeldungen bekommen, wenn wir die Finalisierungen in das Repository mit der github-push-action hochladen wollen.

Die folgenden Job Schritte machen aber gar keine Referenz auf den main-Zweig. Stattdessen übernimmt der Job den Arbeitszweig des aktuellen Jobs aus github.ref. In meinen Fall ist der Workflow auf den main-Zweig beschränkt, so dass in github.ref immer main steht (bzw. bei meiner autotag-action steht master).


    - name: Neue Versionsnummer ermitteln
      id: tagger
      uses: phish108/autotag-action@1.1.31
      with:
        github-token: ${{ secrets.GITHUB_TOKEN }}
        dry-run: 'TRUE'

    - uses: actions/checkout@v2
      with:
        ref: ${{ github.ref }}

    - name: Arbeitsumgebung erstellen
      uses: actions/setup-node@v1
      with:
        node-version: 12
        
    - name: Neue Version finalisieren
      run: | 
        npm ci
        npm --no-git-tag-version --allow-same-version version ${{ steps.tagger.outputs.new-tag }}
        git commit -m "release information for $GITSHA" -a 
      env:
        GITSHA: ${{ github.sha }}
        
    - name: Push changes
      uses: ad-m/github-push-action@master
      with:
        github_token: ${{ secrets.GITHUB_TOKEN }}
        branch: ${{ github.ref }}
    
    - name: Tag mit Versionsnummer erstellen
      uses: phish108/autotag-action@1.1.31
      if: ${{ steps.release.outputs.proceed == 'true' && steps.releaseMain.outputs.proceed == 'true' }}
      with:
        github-token: ${{ secrets.GITHUB_TOKEN }}

Fazit

GitHub Actions sind eine spannende Erweiterung von Github, mit der wir Arbeitsprozesse automatisieren können. Der Vorteil dieser Prozesse ist, dass wir sie durch eigene Funktionen erweitern können. Damit wir diese Funktionen aber einer rigerosen Qualitätskontrolle unterziehen können, braucht es etwas mehr Verständnis über die Arbeitsweise von GitHub Actions. Besonders wichtig sind dabei die Abhängigkeiten von Jobs und wie sich diese auf das Code-Repository auswirken.

Digitalisierung GitHub Automatisierung

Share