Github Actions

6 Minute(n) Lesezeit

Motivation

Warum ich einen Beitrag zu Github Actions schreibe, obwohl es im Internet zahllose Tutorials und Blogeinträge zum Thema gibt? Weil ich für mein letztes Software-Projekt QR-codengrave eine Build Pipeline aufsetzen wollte und sich dies schwieriger gestaltete als angenommen. Sollte ich das an irgend einem Punkt in der Zukunft wiederholen müssen und erinnere mich nicht, wie ich damals in der IDE gebaut, getestet und veröffentlicht habe, kann die Pipeline weiterhelfen.

Ebenso, wenn sich meine IDE ändert oder meine Virtuelle Umgebung defekt ist, die launch.json fehlt oder irgend etwas inkompatibel mit dem von mir verwendeten Compiler/Interpreter wird. Dennoch will ich in der Lage sein, Releases zu veröffentlichen, Bugfixes zu erstellen und Tests zu fahren.

Obwohl das nicht das übliche Argument ist, eine CI/CD Pipeline aufzubauen - normalerweise wird da zuerst die einfachere Zusammenarbeit mehrerer Menschen an einem gemeinsamen Programm(teil) genannt - ist der Anreiz für mich stark genug, mir mal Githubs Automation dafür, bekannt unter dem Namen Github Actions, auszuprobieren.

Meine Software ist zu unbedeutend um “Nightlies” zu bauen oder agil entwickelt zu werden, und daher werde ich die Pipeline auf “build”, “lint/static code checks”, “testing” und “deployment” beschränken und dies auch nur dann automatisch triggern, wenn es Änderungen auf dem Produktionsbranch gibt.

Alles klar, aber warum ein extra Blogeintrag?

Weil es mich so viel Zeit gekostet hat, alles richtig aufzusetzen. Ich habe dafür stundenlang Fehler suchen, oft fluchen und wütend Spaziergänge machen müssen, bevor ich meine in der IDE bereits getestete und lauffähige Software auch mit Github vereinen zu können.

Wäre ich klüger gewesen, hätte ich gleich zu Anfang nach Tools dafür recherchiert wie dieses hier, sodass Github Actions auch lokal ausgeführt werden kann. Dies hätte mir die ständigen Pushes und Wartezeiten auf den nächsten, immer noch fehlerhaften Build ersparen können.

Image: Github Action failed runs

Das Script

Github Actions benutzt YAML um Befehle der Nutzer zu interpretieren. Die Verwendung ist sehr gut dokumentiert und stellt einige plug-and-play Beispiele bereit, die in vielen Szenarien und für die meisten Programmiersprachen direkt passen.

Die sogenannte “Workflowdatei” sieht so aus:

# This workflow will install Python dependencies, run tests and lint with a single version of Python
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python

name: python_integrate

on:
  push:
    branches: [ "master" ]
  pull_request:
    branches: [ "master" ]

permissions:
  contents: read

jobs:
  build:

    runs-on: ubuntu-latest

    steps:
    - uses: actions/checkout@v3
    - name: Set up Python 3.10
      uses: actions/setup-python@v3
      with:
        python-version: "3.10"
    - name: Install dependencies
      run: |
        python -m pip install --upgrade pip
        pip install flake8 pytest
        pip install qrcodegen  # Dependency install of qrcodegen
        if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
    - name: Lint with flake8
      run: |
        # stop the build if there are Python syntax errors or undefined names
        flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
        # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
        flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
    - name: Test with pytest
      uses: GabrielBB/xvfb-action@v1  # Diverts tkinter GUI to a virtual frame buffer (VFB)
      with:
        run: |
          pytest

Dieses sieht nicht einmal groß anders aus als die Vorlage mit der ich gestartet bin. Die einzigen Anpassungen waren der nun vorhandene pytest Aufruf sowie das Hinzufügen eines virtuellen Bildpuffers um Darstellungsprobleme meiner GUI zu lösen. Meine Tests schlugen nämlich fehl sobald die GUI gestartet wurde, da auf den Github Servern gar kein Display zur Verfügung steht.

Github Action runner Fehlermeldungen

Die folgende Liste enthält die Fehler (und zugehörige Lösungen) mit denen ich konfrontiert wurde, bevor ich eine stabile CI aufsetzen konnte - erst nach fast 50 Durchläufen.

Ordner nicht vorhanden?

Output:

/opt/hostedtoolcache/Python/3.10.9/x64/lib/python3.10/importlib/__init__.py:126: in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
test/test_machinify_vector.py:4: in <module>
    from bin.platform.machinify_vector import MachinifyVector, Tool, EngraveParams
E   ModuleNotFoundError: No module named 'bin'

Hintergrund: Ich habe meine Quelldateien unter /bin abgelegt und meiner lokalen Instanz von Pyinstaller (das Werkzeug, was ich auswählte, um aus den Skripten eine ausführbare Datei zu erzeugen) zur Verfügung gestellt. Beim Upload konnte Github Actions die Pfade nicht auflösen und zeigte diesen Fehler an.

Lösung: Eine leere Datei mit Namen __init__.py im /bin Ordner ablegen. Dies zeigt alle im Ordner befindlichen Dateien als Package an, die so für Github Actions verfügbar werden. Die IDE hatte dieses Problem nicht, weil sie alle im Projektordner befindlichen Dateien automatisch indiziert.

YAML Syntaxfehler

Diese Fehler entstanden, weil ich versucht hatte, mehrere Workflows in eine Datei zu schreiben. Github Actions scheint nur einen Workflow pro Datei zu akzeptieren.

Image: Github Action syntax error

Fehler: Pfad-nicht-gefunden

Ich hatte lange Probleme mit solchen Fehlern bedingt durch meine Ordnerstruktur:

- assets  # images, persistence file, etc.
- src  # source files
- test  # pytest files for unit and integration testing
- dist  # build artifacts
- build  # build process files

Ich fand die echt schön aufgeräumt. Aber meine lokale IDE, PyInstaller und Github Actions bzw. dessen worker für Pyinstaller waren sich nicht einig, wie sow etwas aussehen soll. Immer würde sich eine der beteiligten Parteien beschweren, dass ein Pfad nicht existiere oder die Datei soundso nicht gelesen werden könne. Daher habe ich am Ende die Verwendung relativer Pfade verworfen und stattdessen pythons importlib_resources verwendet.

Aber auch hier gab es Probleme mit Pyinstaller auf Github.

Image: Github Action relative path error

Letztendlich konnte ich das Problem erst lösen, als ich den Ordner für assets in src integrierte. Auf diese weise kann der ./ Operator nicht falsch verstanden werden. Obwohl ich dieses Konstrukt nicht so schön finde, war ich immer weniger bereit, noch mehr Zeit dort hinein zu buttern. (Wenn Sie wissen, wie sich das Pfadhandling sowohl lokal als auch remote mit Eleganz lösen lässt, freue ich mich sehr über Ihren Beitrag auf meiner Dissussionsseite).

Tkinter headless

Spät im Entwicklungsprozess habe ich meinen Unit Tests ein paar Integrationstests zur Seite gestellt. So wollte ich sicherstellen, dass Popups die erforderlichen Callbacks an Main machen würden und umgekehrt Daten ausgetauscht werden können, sodass z.B. eine Werkzeugliste an das Konfigfenster übergeben werden kann.

Diese Tests liefen lokal zwar, aber mit einem Nachteil: Wenn ich einen Fehler oder eine Warnung erzeugte, ließ sich das messageBox-Popup nicht automatisiert schließen. Es wollte unbedingt vom Nutzer selbst per Mausklick geschlossen werden. Dies ließ sich auch nicht durch einen Overlay oder Invoke der Click-Aktion umgehen.

Was also in der IDE nervig war, würde auf dem Server in der Automation zu Testabbrüchen führen. Also habe ich die bittere Pille geschluckt und eine Wrapperklasse um tkinters messageBox geschrieben, sodass ich dort ein Mock injizieren konnte was dann wiederum nicht das echte Popup triggern würde.

Ausschnitt:

class MsgBox:
    """Re-implementation due to testing purposes: With this trick, we are able to mock these windows
    so we do not have to wait for users to manually close the dialog, unblocking the application again."""
    def showinfo(self, title, message):
        showinfo(title=title, message=message)

    def error(self, title, message):
        showerror(title=title, message=message)

Als das funktionierte, habe ich die Änderungen voller Hoffnung nach Github gepusht. Und erhielt folgenden, wunderschönen Fehler:

_tkinter.TclError: no display name and no $DISPLAY environment variable

Das war zum Glück leicht zu verstehen - Tkinter wusste nicht, wo es die Fenster hinzeichnen sollte. Erscheint logisch, da der Server headless betrieben wird.

Also durchsuchte ich Foren und wurde mit nur einer Zeile Code belohnt, die dieses Problem sofort behob:

uses: GabrielBB/xvfb-action@v1 # Diverts tkinter GUI to a virtual frame buffer (VFB)

Seitdem führt Github Actions bei Pull request auf main alle tests aus und endlich ergießt sich ein langersehnter Regen grüner Häkchen kühlend auf meine heißgelaufenen Hirnwindungen. Was für ein schönes Gefühl.