CI/CD-Pipelines mit GitHub Actions

CI/CD-Pipelines mit GitHub Actions

Avatar von Christopher Stock

GitHub Actions ermöglicht die Realisierung einer vollständigen Continuous Integration / Continuous Delivery Pipeline direkt in unserem GitHub Repository. In diesem Workshop erstellen wir eine komplette Build- und Release-Pipeline für ein bestehendes Node.js-Projekt und lernen dabei die Funktionsweise von CI/CD sowie die Kernkonzepte von GitHub Actions in der Praxis kennen.

Benötigte Software

GitHub Actions kann in jedem beliebigen GitHub Repository genutzt werden. Zum Ausführen der im Workshop erstellten Prozessschritte ist daher lediglich ein GitHub-Account sowie die Installation eines lokalen Git-Clients erforderlich.

Um die einzelnen Schritte des Workshops auch lokal auf ihrem System ausführen und testen zu können, empfiehlt sich die Installaton der JavaScript-Runtime Node.js. Für den letzten Pipeline-Step „Dockerize“ ist zudem eine Installation von Docker erforderlich.

Folgen Sie den Installationsanweisungen unter Node.js und Docker.

CI/CD in ein bestehendes Projekt einführen

Unser vorgegebenes Projekt verwendet einen Node.js-Tech-Stack und definiert ein minimales REST-API, das mit der Node-Bibliothek Express.js entwickelt wurde. Für die folgenden Szenarien sind bereits npm-Skripte im Projekt definiert:

  • Ausführen der Anwendung
  • Bauen und Paketieren der Anwendung
  • Starten der Unit-Tests
  • Durchführen einer statischen Code-Analyse
  • Generieren der Sourcecode-Dokumentation

Die Ausführung dieser Schritte wollen wir nun automatisieren, indem wir sie kapitelweise in eine neue CI/CD-Pipeline überführen.

Das Projekt forken und klonen

Das GitHub Repository mit dem vorgegebenen Node.js-Projekt findet man auf GitHubActionsWorkshopStart.

Nutzen Sie auf der Webseite dieses Repositories den Button „Fork“, um eine Kopie des Projekts in ein neues Repository ihres eigenen GitHub Accounts zu überführen:

Das neu erstellte Repository steht Ihnen danach zum Klonen auf ihrem lokalen System zur Verfügung. Erweiterungen am Projekt können Sie jederzeit pushen.

Node.js Dependencies installieren

Die Node.js-Projektdatei package.json definiert alle Bibliotheken, die für den Betrieb des Projekts erforderlich sind. Die Installation der Bibliotheken erfolgt über den Node Package Manager:

% npm install

npm-Skript zum Starten der Anwendung

Der Anwendungscode befindet sich in der Skriptdatei src/server.js. Mithilfe der Bibliothek Express.js wird hier ein Webserver mit zwei simplen GET-Routen erstellt und auf Port 8181 des lokalen Systems gestartet.

Im Skript-Bereich unserer package.json ist bereits das npm-Skript start zum Starten der Anwendung definiert:

"scripts": { 
  "start": "node src/server.js", 
  ... 
}

Wir führen das Skript start mit dem Node Package Manager (npm start) aus und starten so unsere Anwendung:

Sofern der im Skript allokierte Port 8181 auf unserem lokalen System noch nicht belegt ist, sollte nun die folgende Ausgabe zu lesen sein:

 
Start Express.js server on http://0.0.0.0:8181
 

Unser Webserver ist nun auf 0.0.0.0:8181 etabliert und wartet auf eingehende Requests. Zum Testen der Funktionalität können wir mit Hilfe des Programms cURL einen HTTP-Request auf die definierte GET-Route /user abschicken. Die vom Server erhaltene Response wird auf der Konsole ausgegeben.

Der cURL sollte nun die empfangenen HTTP-Header und den Response-Body mit drei konstanten User-Objekten im JSON-Format zurückgeben:

% curl -i 0.0.0.0:8181/user

HTTP/1.1 200 OK 
X-Powered-By: Express 
Content-Type: application/json; charset=utf-8 
Content-Length: 135 
ETag: W/"87-gHZz+1+n7pHvd2ovXSzGNYJ6RNM" 
Date: Thu, 14 Oct 2021 07:07:32 GMT 
Connection: keep-alive 
Keep-Alive: timeout=5 

[{"name":"John Smith","username":"jsmith"},{"name":"Jane Williams","username":"jwilliams"},{"name":"Robert Brown","username":"rbrown"}]

Unser erster CI-Job: npm-start – Die Node.js-Anwendung ausführen und requesten

Die letzten beiden Shell-Befehle zum Starten und Requesten der Anwendung lassen wir nun im ersten Schritt unserer CI/CD-Pipeline automatisiert ausführen. Eine Pipeline wird durch einen GitHub Workflow repräsentiert und innerhalb eines GitHub-Repositories im Unterordner .github/workflows/ mittels einer yaml-Datei definiert. Dabei kann der Dateiname frei vergeben werden.

Wir definieren unsere CI/CD-Pipeline in der Workflow-Datei .github/workflows/node.js.yml und legen dort unseren ersten Job npm-start an:

 
name: CI/CD for Node.js app

on:

  push:
    branches: [ '**' ]
  pull_request:
    branches: [ main ]

jobs:

  npm-start:
    name: Start Node.js App
    runs-on: ubuntu-latest
    steps:
    - name: Checkout GitHub Repository
      uses: actions/checkout@v2
    - name: Use Node.js 14.18
      uses: actions/setup-node@v2
      with:
        node-version: 14.18
        cache: 'npm'
    - name: Install npm packages
      run: npm install
    - name: Start app
      run: npm start &
    - name: Send cURL Request to Node.js app
      run: curl -i 0.0.0.0:8181/user
 

Starten der Pipeline

Pushen wir die Workflow-Datei nun in einen beliebigen Branch unseres GitHub Repositories, so wird unsere neue Pipeline serverseitig ausgeführt. Jeder CI/CD-Durchlauf wird auf GitHub im Register „Actions“ des zugehörigen Repositories festgehalten und kann dort genauer inspiziert werden. Unter anderem können hier die einzelnen Durchläufe der Jobs und deren Steps eingesehen werden:

Aufbau der Workflow Datei

 
name:
 

Das Feld name beschreibt diesen Workflow; der Name kann frei vergeben werden.

 
on:
 

Im on-Bereich werden alle GitHub Events definiert, die einen Start dieses Workflows triggern. In unserem Fall wird der Workflow ausgelöst, wenn ein Git Push auf einen beliebigen Branch erfolgt oder wenn ein Pull Request in den main-Branch gemerged wird.

 
jobs:
 

Der Bereich jobs spezifiziert alle Jobs, aus denen dieser Workflow besteht. Ein Job stellt dabei einen in sich abgeschlossenen Prozessschritt der CI/CD-Pipeline dar, der in einer separaten virtuellen Umgebung ausgeführt wird. Daher muss jeder Job das gewünschte Betriebssystem auf dem er läuft explizit definieren. Unser erster Job npm-start gibt hierfür über das Feld runs-on mit ubuntu-latest die aktuellste Version des Betriebssystems Ubuntu an.

 
steps:
 

Der steps-Bereich definiert alle einzelnen Schritte, die beim Ausführen des zugehörigen Jobs sequentiell abgearbeitet werden. Ein Step kann dabei ein beliebiger Shell-Befehl oder eine vordefinierte Action sein. Zahlreiche Actions für die häufigsten Use Cases stehen im GitHub Marketplace zur Verfügung.

Steps im Job npm-start

Die folgenden fünf Schritte sind im steps-Bereich unseres ersten Jobs npm-start definiert und werden von der Pipeline sequentiell ausgeführt:

IDNameBeschreibung
1Checkout GitHub RepositoryDas GitHub Repository wird im aktuellen Arbeitsverzeichnis der CI/CD-Maschine geklont, sodass alle Resourcen dort zur Verfügung stehen.
2Use Node.js 14.18Für die virtuelle Maschine wird eine Node.js-Umgebung auf v.14.18 initialisiert. Danach stehen alle gewohnten Node-Befehle zur Verfügung.
3Install npm packagesEs erfolgt ein Aufruf von npm install durch den alle in der package.json definierten Node.js-Bibliotheken auf der CI/CD-Maschine installiert werden.
4Start appDas npm-Skript start wird über den Aufruf npm start ausgeführt und startet die Applikation auf der CI/CD-Maschine. Durch die Angabe des Ampersands wird dieser Prozess in den Hintergrund verlagert sodass die Ausführung der Pipeline an dieser Stelle nicht blockiert.
5Send cURL Request to Node.js appEs wird ein cURL-Request auf 0.0.0.0:8181/user ausgeführt und die empfangene Response auf der Konsole ausgegeben.

Brechen der Pipeline

Unsere Pipeline weist uns bereits jetzt auf grundsätzliche Probleme in unserer Anwendung hin. Kann die Anwendung nicht erfolgreich gestartet und requested werden, so wird der Build als fehlgeschlagen quittiert. Das Abstürzen der Pipeline kann folgendermaßen getestet werden:

  • Einbau eines Compiler-Fehlers in den JavaScript-Code der Anwendung, bevor der Express.js-Server gestartet wird.

Bauen und Paketieren der App

Nun ist unsere CI/CD-Pipeline erstellt und kann um neue Prozessschritte erweitert werden. Im zweiten Schritt automatisieren wir das Bauen und Paketieren der Anwendung mit einem JavaScript Module Bundler.

Im Projekt wird Webpack als Module Bundler eingesetzt. Beim Ausführen von Webpack wird das Anwendungsskript src/server.js mit allen erforderlichen Node.js-Bibliotheken zusammengefasst und in die eigenständige JavaScript-Datei ./public/app-bundle.js gebündelt.

npm-Skript zum Bauen via Webpack

Im Skript-Bereich der package.json ist das npm-Skript webpack-production für den Aufruf des Tools Webpack definiert:

 
  "scripts": {
    ...
    "webpack-production": "webpack –mode=production",
    ...
  }
 

Das Skript können wir über den Node Package Manager mit npm run webpack-production aufrufen:

Nach dem Durchlauf von Webpack wurde die Datei public/app-bundle.js generiert. Da im Skript die Option mode=production angegeben ist, hat Webpack die generierte JavaScript-Datei zudem minifiziert. Die erzeugte Datei beinhaltet nun den Anwendungscode und alle darin verwendeten Node-Bibliotheken. Somit kann unser Programm nun auch außerhalb unseres Projektordners mittels des node-Befehls ausgeführt werden:

 
    % node public/app-bundle.js
Start Express.js server on http://0.0.0.0:8181
 

2.2. Unser zweiter CI-Job: build-production – Die Anwendung mit Webpack bauen

Wir automatisieren nun das Bauen unserer Anwendung mittels Webpack. Dafür erstellen wir unseren zweiten Job build-production und rufen dort das npm-Script webpack-production auf. Anschließend wird die generierte Datei public/app-bundle.js als Build-Artefakt exportiert.

 
...

  build-production:
    name: Build Production App
    runs-on: ubuntu-latest
    steps:
    - name: Checkout GitHub Repository
      uses: actions/checkout@v2
    - name: Use Node.js 14.18
      uses: actions/setup-node@v2
      with:
        node-version: 14.18
        cache: 'npm'
    - name: Install npm packages
      run: npm install
    - name: Build Production App Bundle
      run: npm run webpack-production
    - name: Upload Production App Bundle to Build Artefacts
      uses: actions/upload-artifact@v1
      with:
        name: app-bundle
        path: public/app-bundle.js
 

Parallele Ausführung

Da an das Starten des neuen Jobs build-production keine Bedingungen geknüpft sind, wird er in der CI/CD-Pipeline zeitgleich mit unserem ersten Job npm-run ausgeführt.

Steps im Job build-production

Auch in unserem zweiten Job beziehen sich die ersten drei Steps lediglich auf die Initialisierung der Node.js-Umgebung. Sie sind daher identisch mit denen des vorhergehenden Jobs npm-start.

IDNameBeschreibung
1Checkout GitHub RepositoryIdentisch zum ersten Job npm-start
2Use Node.js 14.18
3Install npm packages
4Build Production App BundleDas npm-Skript webpack-production wird auf der CI/CD-Maschine gestartet. Dies generiert die alleinstehende und minifizierten JavaScript-Datei public/app-bundle.js.
5Upload Production App Bundle to Build ArtefactsHier wird erstmals eine vorgegebene Action aufgerufen. Die Action actions/upload-artifact lädt die gebaute Anwendung public/app-bundle.js zu den Build Artefakten hoch.

Build-Artefakte stehen nach dem Durchlauf der Pipeline zum Download zur Verfügung. Auf der Übersichtsseite des Pipeline-Laufs werden sie nach dem Durchlauf der Pipeline unter Artifacts aufgelistet:

Build-Artefakte kommen oft auch bei der Bereitstellen von Log-Dateien für bestimmte Tools oder Prozesse zum Einsatz. Darüberhinaus können Build-Artefakte auch von nachfolgenden CI/CD-Jobs heruntergeladen und weiterverarbeitet werden.

Brechen der Pipeline

Scheitert der Build mit Webpack, so weist uns die Pipeline nun darauf hin. Dieses Szenario kann folgendermaßen getestet werden:

  • Einbau eines Compiler Fehlers in den JavaScript-Code der Anwendung.
  • Einbau eines Fehlers in die Webpack-Konfigurationsdatei webpack.config.js.

Unit Tests

Im Projekt wird das Test-Framework Jest eingesetzt – es ermöglicht die Abdeckung unseres Produktionscodes mit Unit Tests und das Sammeln einer Test Code Coverage. Zudem können damit zukünftige Erweiterungen an unserer Applikation testgetrieben entwickelt werden.

In der JavaScript-Datei test/server.test.js ist eine Test Suite mit drei Test Cases formuliert. In jedem Test Case wird die Anwendung neu gestartet und eine bestimmte Route via HTTP angefordert.

npm-Skript zum Ausführen der Unit Tests

Im Script-Bereich unserer package.json ist der Aufruf von Jest als npm-Skript festgehalten:

 
  "scripts": {
    ...
    "test": "jest –ci",
    ...
  }
 

Damit können wir die Tests über den Node Package Manager mit npm test ausführen.

Test- und Coverage-Reports

Neben den Ausgaben des Testlaufs auf der Kommandozeile werden von Jest zwei Test-Reports generiert:

  • Ein Test-Report im JUnit XML Format mit den Ergebnissen des Testlaufs in der Datei public/test-results.xml.
  • Die Ergebnisse der Code Coverage im Clover XML Format in der Datei public/coverage/clover.xml.

CI-Job Nummer Drei: run-unit-tests – Unit Tests und Code Coverage ausführen

Das Ausführen automatisierter Tests ist wohl einer der gebräuchlichsten Anwendungsfälle einer Continuous-Integreation-Pipeline und stellt einen wichtigen Faktor zur Sicherstellung einer hohen Code Qualität dar.

Wir ergänzen unseren Workflow um einen weiteren Job, indem wir das npm-Skript test automatisiert aufrufen. Die generierten Test- und Coverage-Reports werden nach Abschluss der Tests als Build-Artefakte exportiert.

 
...

  run-unit-tests:
    name: Unit Tests
    needs: build-production
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        node-version: [14.x, 16.x]
        # See supported Node.js release schedule at
        # https://nodejs.org/en/about/releases/
        os: [ubuntu-latest, ubuntu-18.04]
    steps:
    - name: Checkout GitHub Repository
      uses: actions/checkout@v2
    - name: Use Node.js ${{ matrix.node-version }}
      uses: actions/setup-node@v2
      with:
        node-version: ${{ matrix.node-version }}
        cache: 'npm'
    - run: npm install
    - run: echo Running Jest Unit tests
    - run: npm test
    - name: Upload Test Report Artefact
      uses: actions/upload-artifact@v1
      with:
        name: test-results-${{ matrix.os }}-${{ matrix.node-version }}
        path: public/test-results.xml
    - name: Upload Test Coverage Report Artefact
      uses: actions/upload-artifact@v1
      with:
        name: test-coverage-${{ matrix.os }}-${{ matrix.node-version }}
        path: public/coverage/clover.xml
 

Bedingter Start des Jobs

 
    needs: build-production
 

Der Job wird nur dann gestartet, wenn der zuvor definierte Job build-production erfolgreich abgeschlossen wurde. Hierdurch wird auf einen unnötige Start der Tests verzichtet, falls die Anwendung gar nicht korrekt kompiliert oder gebaut werden konnte.

In der grafischen Übersicht des Workflows wird diese Abhängigkeit und die damit festgelegte sequentielle Ausführung der beiden Jobs run-unit-tests und build-production ersichtlich:

Einsatz der Build Matrix

 
    strategy:
      matrix:
 

GitHub Actions ermöglicht das Ausführen eines Jobs auch unter Verwendung einer Build Matrix. Hierdurch werden Jobs wiederholt mit allen möglichen Kombinationen der spezifizierten Matrix durchgeführt. In unserem Fall für alle Kombinationen der spezifizierten Betriebssysteme und Node.js-Umgebungen. Die eingesetzten Werte können innerhalb des Jobs über die beiden Umgebungsvariablen ${{ matrix.node-version }} und ${{ matrix.os }} verwendet werden.

In unserem Fall wird nach jedem Aufruf der Tests der Dateiname der beiden Build-Artefakte um diese beiden Variablen erweitert. So bleibt nachvollziehbar, unter welchen Umständen diese Reports generiert wurden.

Brechen der Pipeline

Scheitert der Testlauf, so kennzeichnet unsere Pipeline den Workflow als fehlgeschlagen. Das kann folgendermaßen getestet werden:

  • Entfernen der im Livecode definierten Route /.
  • Verändern der in den Tests erwarteten JSON-Objekte für die Route /user.
  • Unterschreiten des in der Jest-Config jest.config.js definierten Code-Coverage-Schwellwerts von 80 Prozent, beispielsweise durch das Entfernen des Test Cases für die Route /user.

Statische Code Analyse

Der Einsatz eines Code Style Linters ermöglicht die Sicherstellung eines einheitlichen Code Styles für unseren JavaScript-Quellcode. Wird beim Aufruf des Linters im Projektcode eine Abweichung vom vordefinierten JavaScript-Code-Style gefunden, so meldet der Linter einen Fehler.

Im Projekt wird der Linter ESLint eingesetzt und der sicherzustellende Code Style in der Datei .eslintrc.js festgehalten. Hier sind ein paar gebräuchliche Code-Style-Regeln definiert – unter anderem ist eine Einrückungstiefe von vier Spaces und eine maximale Zeilenlänge von 120 Zeichen festgelegt.

npm-Skripte für ESLint

Analog zu den Tests kann auch für ESLint die Generierung eines Reports im JUnit XML-Format angegeben werden. In diesem Fall erzeugt ESLint leider keine Ausgabe etwaige Linter-Fehler auf der Konsole, was die manuelle Sichtung der Resultate erschwert. Als Workaround wurden im Projekt einfach zwei npm-Skripte für den Einsatz von ESLint in der package.json festgehalten:

 
  "scripts": {
    ...
    "eslint-scan": "eslint \"{src,test}/**/*.{js,jsx}\"",
    "eslint-report": "eslint --format junit -o public/linter-results.xml \"{src,test}/**/*.{js,jsx}\"",
    ...
  }
 

ESLint ausführen

Lokal können wir ESLint nun mit dem Skript eslint-scan (npm run eslint-scan ausführen. Auftretende Linter-Fehler werden dabei auf der Konsole ausgegeben.

Linter-Report erstellen

Auch das zweite ESLint-Skript eslint-report führt den Linter mit npm run eslint-report lokal aus. Dabei werden eventuelle Linter-Fehler aber nicht auf der Konsole ausgegeben, sondern ausschließlich in der generierten Report-Datei public/linter-results.xml festgehalten.

Unser vierter CI-Job: run-code-inspection – Statische Code Analyse durchführen

Nun automatisieren wir den Durchlauf unseres Code Style Linters und schaffen damit eine Continuous Inspection in unserer CI/CD-Pipeline. Der neuen Job run-code-inspection ruft beide npm-Skripte für ESLint hintereinander auf – so werden eventuelle Linter-Fehler im Log der Pipeline deutlich festgehalten und zudem die Report-Datei public/linter-results.xml generiert. Letztere wird anschließend als Build-Artefakt exportiert.

 
...

  run-code-inspection:
    name: Code Inspection
    needs: build-production
    runs-on: ubuntu-latest
    steps:
    - name: Checkout GitHub Repository
      uses: actions/checkout@v2
    - name: Use Node.js 14.18
      uses: actions/setup-node@v2
      with:
        node-version: 14.18
        cache: 'npm'
    - run: npm install
    - name: Run ESLint Scan on app sourcecode
      run: npm run eslint-scan
    - name: Generate ESLint Report from app sourcecode scan
      run: npm run eslint-report
    - name: Upload Linter Report Artefact
      uses: actions/upload-artifact@v1
      with:
        name: linter-results
        path: public/linter-results.xml
 

Bedingter Start des Jobs

 
    needs: build-production
 

Auch dieser Job hat als Startbedingung den erfolgreichen Abschluss des Jobs build-production angegeben. Somit wird er nach dessen erfolgreichem Abschluss parallel zu dem Job run-unit-tests ausgeführt. Dies wird in der Übersicht unserer Pipeline ersichtlich:

Brechen der Pipeline

Ein Scheitern des Linters führt zu einem fehlgeschlagenen Durchlauf der Pipeline. Das kann beispielsweise durch eine der folgenden Änderungen am JavaScript-Quellcode der Anwendung oder der Tests reproduziert werden:

  • Einfügen eines unerwarteten Whitespaces.
  • Überschreiten der maximal erlaubten Zeilenlänge von 120 Zeichen.
  • Falsche Einrückung einer beliebigen Zeile.

Generieren einer Sourcode Dokumentation

Im Projekt wird JSDoc zur Generierung einer Sourcode-Dokumentation im HTML-Format eingesetzt. Die Dokumentation wird dabei für den JavaScript-Projektcode in src/server.js erstellt.

npm-Skript zum Ausführen von JSDoc

Der Aufruf des Tools JSDoc ist bereits in einem npm-Skript festgehalten. Als Zielordner für die zu generierende HTML-Dokumentation ist hier der Ordner public/jsdoc angegeben. Als Eingangsdateien wird lediglich unser Programmskript src/server.js angegeben – für unsere Tests in test/server.test.js wird also per Design keine Sourcecode-Dokumentation erstellt.

 
  "scripts": {
    ...
    "jsdoc": "jsdoc --destination public/jsdoc src/server.js",
    ...
  }
 

Auch dieses Skript können wir über den Node Package Manager mit Hilfe von npm run jsdoc starten.

Generierte Sourcecode-Dokumentation

Nach dem Durchlauf von JSDoc wurde die HTML-Dokumentation im Verzeichnis public/jsdoc erstellt. Zur Einsicht der Dokumentation können Sie die Indexseite public/jsdoc/index.html in Ihrem Browser öffnen.

CI-Job Nummer Fünf: generate-jsdoc – Sourcode Dokumentation generieren

Wir rufen das npm-Skript jsdoc im neuen Job generate-jsdoc unseres Workflows auf. Nach dem Durchlauf von JSDoc wird das gesamte Verzeichnis public/jsdoc als Build-Artefakt exportiert.

 
...

  generate-jsdoc:
    name: Generate Documentation
    needs: [run-unit-tests, run-code-inspection]
    runs-on: ubuntu-latest
    steps:
    - name: Checkout GitHub Repository
      uses: actions/checkout@v2
    - name: Use Node.js 14.18
      uses: actions/setup-node@v2
      with:
        node-version: 14.18
        cache: 'npm'
    - run: npm install
    - run: npm run jsdoc
    - name: Upload JSDoc documentation
      uses: actions/upload-artifact@v1
      with:
        name: jsdoc-sourcecode-documentation
        path: public/jsdoc
 

Bedingter Start des Jobs

 
    needs: [run-unit-tests, run-code-inspection]
 

Die Sourcecode-Dokumentation soll erst erstellt werden, nachdem die beiden vorhergehenden Jobs run-unit-tests und run-code-inspection erfolgreich abgeschlossen wurden.

Brechen der Pipeline

Sollte die Generierung der Sourcecode-Dokumentation scheitern, kennzeichnet die Pipeline den Build als fehlgeschlagen. Dieser Fall ist eher unwahrscheinlich und kann beispielsweise durch eine falsche Konfiguration folgendermaßen nachgestellt werden:

  • Umbenennen der Datei src/server.js, sodass JSDoc die im npm-Skript definierte Eingabedatei nicht mehr findet.

Erstellen eines GitHub Release Items

Ein GitHub Release repräsentiert eine veröffentlichte Version unseres Programms und wird anhand einer getaggten Git Revision erstellt. Einem GitHub Release Item kann zudem auch ein oder mehrere ausgewählte Build Artefakte beigefügt werden.

GitHub Releases werden ausschließlich auf dem GitHub-Server angelegt und können nicht lokal realisiert oder getestet werden – daher existiert hierfür auch kein npm-Skript.

CI-Job Nummer Sechs: create-github-release – Erstellen eines GitHub Releases beim Pushen eines Tags mit semantischer Versionsnummer

Wir gestalten das Release-Management für unser Node.js-Projekt nun Git Aware und lassen unsere CI/CD-Pipeline automatisch ein GitHub Release Item erstellen, sofern ein Git Tag mit einer semantischen Versionsnummer ins Repository gepusht wird.

Unser Workflow startet aktuell noch nicht, wenn ein Git Tag gepusht wird. Daher fügen wir dieses neue Event zum on-Bereich unseres Workflows hinzu. Als erwarteter Tagname wird das RegEx-Pattern *.*.* angegeben.

 
name: CI/CD for Node.js app

on:

  push:
    branches: [ '**' ]
    tags: [ '*\.*\.*' ]
  pull_request:
    branches: [ main ]

jobs:

...
 

Nun fügen wir unseren neuen Job create-github-release zu unserem Workflow hinzu. Der Release-Build unserer Applikation wird dem Benutzer so standardisiert zur Verfügung gestellt. Dieser Prozessschritt fällt in einer CI/CD-Pipeline unter den Begriff Continuous Delivery.

 
...

  create-github-release:
    name: Create GitHub Release
    if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
    needs: generate-jsdoc
    runs-on: ubuntu-latest
    steps:
    - name: Parse release version from git tag
      run: echo "release-version=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
    - name: Print out parsed release version
      run: echo Parsed release version is:${{ env.release-version }}
    - name: Download Production Build Artefact
      uses: actions/download-artifact@v1
      with:
        name: app-bundle
        path: public
    - name: Delete Production Build Artefact
      uses: geekyeggo/delete-artifact@v1
      with:
        name: app-bundle
    - name: Rename Production Build Artefact by inserting release version
      run: mv "public/app-bundle.js" "public/app-bundle-v${{ env.release-version }}.js"
    - name: Upload Renamed Production Build Artefact
      uses: actions/upload-artifact@v1
      with:
        name: app-bundle-v${{ env.release-version }}
        path: public/app-bundle-v${{ env.release-version }}.js
    - name: Create GitHub Release
      uses: ncipollo/release-action@v1
      with:
        name: Release v${{ env.release-version }}
        token: ${{ secrets.GITHUB_TOKEN }}
        tag: ${{ env.release-version }}
        artifacts: 'public/app-bundle-v${{ env.release-version }}.js'
        allowUpdates: true
        omitBody: true
        removeArtifacts: true
 

Bedingte Job-Ausführung

 
    needs: generate-jsdoc
 
 
    if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
 

Durch die kombinierte Angabe der beiden Felder needs und if wird unser neu geschaffener Job create-github-release nur dann ausgeführt, wenn der Job generate-jsdoc erfolgreich abgeschlossen wurde und wenn ein Git Tag gepusht wurde.

Steps im Job create-github-release

Im Gegensatz zu allen bisherigen Jobs muss diesmal kein npm-Skript ausgeführt werden. Somit muss für diesen Job auch keine Node.js-Umgebung initialisiert oder installiert werden.

IDNameBeschreibung
1Parse release version from git tagDer Job parst den Namen des gepushten Tags und speichert ihn in der Umgebungsvariable ${{ env.release-version }}.
2Print out parsed release versionDie in der Umgebungsvariablen ${{ env.release-version }} gespeicherte Release-Versionsnummer wird auf der Konsole zu Debugzwecken ausgegeben.
3Download Production Build ArtefactDas vom Job build-production hochgeladene Build-Artefakt mit der paketierten Applikation wird heruntergeladen und auf dem CI/CD-Server unter public/app-bundle.js gespeichert. Hierfür wird die vorgegebene Action actions/download-artifact verwendet.
4Delete Production Build ArtefactDas vom Job build-production hochgeladene Build-Artefakt wird aus den Build-Artefakten gelöscht. Hierfür wird die vorgegebene Action actions/delete-artifact verwendet.
5Rename Production Build Artefact by inserting release versionDie heruntergeladene Datei public/app-bundle.js wird auf dem CI/CD-Server umbenannt indem der Dateiname um die Release-Versionsnummer erweitert wird.
6Upload Renamed Production Build ArtefactDie umbenannte Datei mit der paketierten Anwendung wird als ein neues Build-Artefakt hochgeladen.
7Create GitHub ReleaseDie Erstellung des GitHub Releases wird abschließend mit der vorgegebenen GitHub Action ncipollo/release-action realisiert. Dabei wird das Build Artefakt mit der paketierten Applikation dem Release hinzugefügt.

Testen des Jobs

Zum Testen unseres neuen Jobs create-github-release committen wir die Erweiterung unserer Workflow-Datei und fügen ein Git Tag mit einer Versionsnummer zu dieser Revision hinzu. Pushen wir anschließend dieses Tag, so können wir den Durchlauf unseres neuen Jobs und die Erstellung eines neuen Release Items auf GitHub beobachten.

 
    % git tag 1.0.0 HEAD
    % git push origin 1.0.0
 

Alle Release-Items eines GitHub Repositories werden auf der Webseite im Tab Code unter Releases angezeigt.

Deployment und Betrieb der Anwendung in einem Docker Container

Wir können unsere Anwendung in einer gleichbleibenden Umgebung betreiben, testen und weitergeben, indem wir sie in einen Docker-Container verpacken. Hierfür wurde in der Root unseres Projekts bereits ein einfaches Dockerfile erstellt:

 
# base image for this container
FROM node:14

# make container's port 8181 accessible to the outside
EXPOSE 8181

# copy entire 'public' directory into the container
COPY public/* public/

# run the app bundle with node
CMD ["node", "./public/app-bundle.js"]
 

Als Basis-Image wird hier die Dockerumgebung Node.js v.14 verwendet. Der von unserer Anwendung verwendete Port 8181 wird innerhalb des Docker-Containers nach außen zugänglich gemacht. Anschließend wird unser lokaler Pfad public mitsamt dessen Inhalt in das Arbeitsverzeichnis des Docker-Containers kopiert und die dadurch im Container verfügbare Anwendung in der Datei ./public/app-bundle.js unter Verwendung des node-Befehls gestartet.

Erstellen des Docker Images

Bevor ein neuer Docker-Container instanziiert werden kann, muss aus dem Dockerfile ein neues Docker-Image erstellt werden. Das erfolgt mit Hilfe des Befehls docker build in unserer Projekt Root und weist dabei dem neu erstellten Docker-Image den Namen express-js-app:14.18 zu.

 
    % docker build . --tag express-js-app:14.18
 

Das neu erstellte Docker-Image sollte danach mit docker images in der Liste der lokal verfügbaren Docker Images zu finden sein.

Bauen und Starten des Docker Containers

Mit dem Befehl docker run kann aus dem Docker Image ein neuer Docker-Container erstellt und gestartet werden:

 
    % docker run --name express-js-app-container --detach --publish 45678:8181 --tty express-js-app:14.18
 

Für den neuen Container wird der Name express-js-app-container vergeben. Die Angabe der Option --detach startet den Container im Hintergrund und löst diesen somit vom aufrufenden Prozess los. Die Option --publish mappt den exponierten Port 8181 innerhalb des Containers auf den Port 45678 unseres Host Systems.

Der gestartete Docker Container sollte nach einem Aufruf von docker container ls mit dem Status „Up“ gelistet werden

Requesten unserer Anwendung im Docker Container

Wir können die Anwendung im laufenden Docker Container nun ansprechen, indem wir mit curl -i 0.0.0.0:45678/user einen cURL auf den gemappten lokalen Port 45678 durchführen:

Es sollte nun eine erfolgreiche Response der Route /user aus der im Docker-Container laufenden Anwendung empfangen worden sein:

 
HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 135
ETag: W/"87-gHZz+1+n7pHvd2ovXSzGNYJ6RNM"
Date: Fri, 15 Oct 2021 08:43:21 GMT
Connection: keep-alive
Keep-Alive: timeout=5

[{"name":"John Smith","username":"jsmith"},{"name":"Jane Williams","username":"jwilliams"},{"name":"Robert Brown","username":"rbrown"}]
 

Stoppen des laufenden Docker-Containers

Mit dem folgenden Befehl können wir den laufender Docker-Container auch wieder stoppen:

 
    % docker stop express-js-app-container
 

CI-Job Nummer Sieben: dockerize – Dockerisierung unserer Anwendung

Wir fügen nun unseren letzten Job dockerize zu unserem Workflow hinzu. Dabei können wir die beiden zuletzt verwendeten Docker-Kommandos eins zu eins übernehmen:

 
...

  dockerize:
    runs-on: ubuntu-latest
    needs: build-production
    name: Dockerize Production App
    steps:
      - name: Checkout GitHub Repository
        uses: actions/checkout@v2
      - name: Download Production Build Artefact
        uses: actions/download-artifact@v1
        with:
          name: app-bundle
          path: public
      - name: Build Docker Image from Dockerfile
        run: docker build . --tag express-js-app:14.18
      - name: Create and run Docker Container from Docker Image
        run: docker run --name express-js-app-container --detach --publish 45678:8181 --tty express-js-app:14.18
      - name: Sleep 5 seconds (wait till Express.js app is running)
        run: sleep 5s
      - name: Send a cURL request to the Docker container
        run: curl -i 0.0.0.0:45678/user
      - name: Stop the Docker Container
        run: docker stop express-js-app-container
      - name: Wait until Docker Container is down
        run: docker wait express-js-app-container
 

Auch dieser Job kommt ohne die Initialisierung einer Node.js-Umgebung aus. Vor dem Erstellen des Docker-Containers wird das Build-Artefakt app-bundle ins Verzeichnis public heruntergeladen, damit es in den Docker-Container deployed werden kann. Da der Container im Hintergrund gestartet wird, erfolgt anschließend eine Verzögerung von 5 Sekunden bevor der cURL-Request an die Anwendung im Container gesendet wird. Anschließend wird der Docker Container wieder gestoppt.

Der GitHub Marketplace bietet zudem Actions, mit denen ein Docker-Image unter Angabe der entsprechenden Authentifizierung direkt ins Docker Hub gepusht oder in ein Orchestrierungssystem wie Kubernetes deployed werden kann. In unserer CI/CD-Pipeline wird dieser Schritt auch als Continuous Deployment bezeichnet.

Pipeline Tutto Completto

Unser GitHub-Actions-Workflow ist nun komplett. In der schematischen Darstellung ist jetzt auf einen Blick ersichtlich, welche Jobs unserer Build- und Release-Pipeline parallel und welche sequentiell ausgeführt wurden:

CI/CD im Handumdrehen

GitHub Actions ermöglicht eine schnelle und leichtgewichtige Realisierung vollständiger CI/CD-Pipelines. Dabei sind keine externen Ressourcen wie Build-Server oder CI/CD-Services erforderlich. Unterstützt werden auch Multi-Container-Setups und Testing via Docker. Darüber hinaus bietet Github zahlreiche vorgegebene CI-Templates für diverse Umgebungen und Tech Stacks.

Entwicklern ermöglicht das einen sehr schnellen Start in das Thema CI/CD. Auch die große Community und die große Anzahl an Actions die im GitHub Marketplace zur Verfügung stehen und mit der die häufigsten Use Cases schnell umgesetzt werden können, zahlen auf die hohe Developer Experience ein.

Ich freue mich wenn ich Ihnen einen schnellen Einstieg in die Kernkonzepte von GitHub Actions und in die praktische Arbeit mit CI/CD-Pipelines geben konnte. Für Feedback oder Rückfragen können Sie mich gerne via christopher.stock@mayflower.de erreichen.

Bild von fanjianhua auf Freepik

Avatar von Christopher Stock

Kommentare

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert


Für das Handling unseres Newsletters nutzen wir den Dienst HubSpot. Mehr Informationen, insbesondere auch zu Deinem Widerrufsrecht, kannst Du jederzeit unserer Datenschutzerklärung entnehmen.