Eine CI/CD Pipeline für Static Sites mit GitHub Actions

Mit GitHub Actions lässt sich innerhalb kürzerster Zeit eine Continuous Integration bzw. Continuous Delivery Pipeline erstellen, die eine Static Site generiert und deployed. In diesem Beitrag zeige ich dir, wie ich die Pipeline für meinen Blog aufgebaut habe und welche Optimierungen ich vorgenommen habe, damit die Pipeline möglichst schnell durchläuft.

Das Ziel

CI/CD verfolgt das Ziel, einen konsistenten und automatisierten Prozess zu etablieren mit dem Anwendungen gebaut, getestet und deployed werden. Dadurch wird es möglich, dass Änderungen an einer Anwendung in kleinen, inkrementellen Schritten gemacht werden und so schnell wie möglich live gehen.

Das gleiche Ziel möchte ich auch mit der CI/CD Pipeline für meinen Blog erreichen. Egal ob Änderungen am Layout, neuer Content oder Korrekturen an bestehendem Content; mein Ziel ist es, dass ich diese jederzeit ins Git Repository einchecken kann und sie zwei Minuten später live sind.

Aufbau eines GitHub Actions Workflows

Um einen Workflow mit GitHub Actions zu erstellen, müssen wir in unserem Repository als erstes ein YAML File für den Workflow anlegen. Das File muss sich im Verzeichnis .github/workflows befinden, damit es als Workflow erkannt wird. Der Name des Files ist egal. Ich nenne mein File build-and-deploy.yml und füge zunächst einmal den folgenden Inhalt ein:

name: build-and-deploy

on: push

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout Code
        uses: actions/checkout@v2

Mit name: build-and-deploy geben wir unserem Workflow einen Namen. Ich habe den Workflow gleich wie das File benennt, das ist jedoch nicht nötig. Als nächstes definieren wir mit on: push wann der Workflow ausgeführt werden soll. In diesem Fall bei jedem Push ins Git Repository.

Nun folgt der interessante Teil. Ein GitHub Actions Workflow besteht aus einem oder mehreren Jobs und diese wiederum aus einem oder mehreren Steps bzw. Actions. In unserem Fall besteht der Workflow aus einem Job mit dem Identifier build-and-deploy. Wir geben mit runs-on: ubuntu-latest an, dass der Job in einer Ubuntu Umgebung laufen soll und definieren anschliessend die Steps, die ausgeführt werden sollen. Zu Beginn starten wir mit einem einzigen Step der unseren Sourcecode auscheckt, damit wir anschliessend damit arbeiten können.

Wenn wir die Änderungen so commiten und auf GitHub pushen, wird automatisch der dazugehörige Workflow angelegt und ausgeführt. Wir können dies auf GitHub überprüfen:

Unser Workflow wird automatisch erstellt und ausgeführt.

Generieren der Static Site

Wir haben nun das Grundgerüst für unsere CI/CD Pipeline und können damit beginnen, die Static Site durch die Pipeline generieren zu lassen. Meinen Blog generiere ich mit dem Static Site Generator Hugo. Hugo sowie einige andere Abhängigkeiten habe ich mit Yarn installiert. Um die Static Site durch die Pipeline generieren zu lassen, müssen wir also zunächst alle Abhängigkeiten mit Yarn installieren. Dazu fügen wir einen zusätzlichen Step nach dem Auschecken des Sourcecodes hinzu:

name: build-and-deploy

on: push

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout Code
        uses: actions/checkout@v2
      - name: Install Dependencies
        run: yarn --frozen-lockfile

Die Syntax sollte selbsterklärend sein: Mit name: Install Dependencies geben wir dem Step einen Namen und mit run: yarn --frozen-lockfile definieren wir, welcher Befehl ausgeführt werden soll. In diesem Fall installieren wir die Abhängigkeiten mit Yarn und geben durch --frozen-lockfile an, dass genau die Versionen verwendet werden sollen mit denen wir lokal gearbeitet haben und die im Lockfile von Yarn spezifiziert sind.

Als nächstes können wir unsere Static Site generieren. Ich habe in meinem package.json ein Build Skript definiert, dass ich mittels yarn build aufrufen kann. Das Gleiche machen wir nun als weiteren Step in unserem Workflow:

name: build-and-deploy

on: push

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout Code
        uses: actions/checkout@v2
      - name: Install Dependencies
        run: yarn --frozen-lockfile
      - name: Build Site
        run: yarn build

Sobald wir die Änderungen in unser Repository pushen wird der Workflow erneut gestartet und wir sehen dass die Static Site generiert wird:

Die Pipeline generiert unsere Static Site.

Wir sehen auch, dass der Workflow bereits eine halbe Minute dauert. Und das ist ein vergleichsweise schneller Durchlauf. Das Installieren der Abhängigkeiten dauert teilweise um ein Vielfaches länger. Und mit zunehmender Anzahl Abhängigkeiten sowie weiteren Steps wird der Workflow in Zukunft eher mehr Zeit in Anspruch nehmen als weniger. Wie wir den Workflow optimieren können zeige ich dir später.

Deployment der Static Site auf Netlify

Nachdem wir die Static Site generiert haben wird es Zeit diese direkt aus unserer Pipeline heraus zu deployen. Als Hosting Provider setze ich Netlify ein. Um das Deployment auf Netlify zu automatisieren existiert eine CLI, die ich als Abhängigkeit im package.json aufgelistet habe. Wir haben in unserer Pipeline also bereits alles installiert was wir für das Deployment brauchen und müssen lediglich einen zusätzlichen Step definieren:

name: build-and-deploy

on: push

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout Code
        uses: actions/checkout@v2
      - name: Install Dependencies
        run: yarn --frozen-lockfile
      - name: Build Site
        run: yarn build
      - name: Deploy Site
        run: yarn netlify deploy -a $ -s $ -d public -p

Im Deployment Step referenzieren wir die beiden Secrets NETLIFY_AUTH_TOKEN und NETLIFY_SITE_ID. Damit die Pipeline auf diese Secrets zugreifen kann, müssen wir diese in den Projekt Einstellungen auf GitHub hinterlegen. Die Secrets findest du unter Settings > Secrets:

Secrets für unser Deployment können wir auf GitHub sicher hinterlegen.

Sobald wir die Secrets hinterlegt haben können wir unsere Änderungen ins Repository pushen und das Deployment wird ausgeführt:

Mit der CI/CD Pipeline deployen wir unsere Site innerhalb kürzester Zeit auf Netlify.

Optimieren der Pipeline

Damit hätten wir unser Ziel eigentlich erreicht: Die Static Site wird in weniger als zwei Minuten gebaut und deployed. Doch unsere CI/CD Pipeline besteht momentan nur aus dem nötigsten. In Zukunft plane ich diverse weitere Schritte hinzuzufügen, die die Site testen und dabei zum Beispiel prüfen, ob sich aus Versehen falsche Links oder fehlende Bilder eingeschlichen haben. Ausserdem wird die Liste der Abhängigkeiten mit der Zeit sehr wahrscheinlich länger und die Ausführung der Pipeline wird dadurch länger und länger dauern.

Es macht deshalb Sinn das wir so früh wie möglich damit beginnen unsere Pipeline zu optimieren. Eine Optimierung die wir jetzt schon machen können ist das Cachen von Abhängigkeiten, damit diese nicht jedes Mal von neuem Installiert werden müssen. Um dies zu erreichen benutzen wir einen Cache Step:

name: build-and-deploy

on: push

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout Code
        uses: actions/checkout@v2
      - name: Restore Node Modules
        uses: actions/cache@v2
        with:
          path: node_modules
          key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }}
          restore-keys: |
            ${{ runner.os }}-modules-
      - name: Install Dependencies
        run: yarn --frozen-lockfile
      - name: Build Site
        run: yarn build
      - name: Deploy Site
        run: yarn netlify deploy -a ${{ secrets.NETLIFY_AUTH_TOKEN }} -s ${{ secrets.NETLIFY_SITE_ID }} -d public -p

Wie du siehst benutzen wir dieses Mal keinen einfachen Befehl sondern eine vordefinierte Action. Die Action die wir benutzen geben wir mittels uses: actions/cache@v2 an. In diesem Fall ist dies die integrierte Cache Action. Es existieren noch eine Vielzahl weiterer integrierter Actions sowie unzählige Actions, die von der Community erstellt worden sind. Du kannst dir deine Pipeline also nach Belieben aus existierenden Blöcken zusammenstellen. Auch für das Bauen einer Hugo Site oder das Deployment auf Netlify würden Community Actions existieren. Ich bevorzuge es jedoch, nur integrierte Actions zu verwenden und den Rest über eigene Befehle zu machen, damit ich jederzeit weiss was die Pipeline macht.

Der Cache Action geben wir mittels with mehrere Parameter mit. So zum Beispiel den Pfad, der gecached werden soll sowie einen Key der benutzt werden soll, um zu Prüfen ob Daten im Cache vorhanden sind. Wenn wir die Änderungen pushen, sehen wir das unsere Pipeline nun zwei neue Steps ausführt:

Der Cache Step speichert unsere Abhängigkeiten in einem Cache.

Der Restore Node Modules Step lädt Daten aus dem Cache sofern vorhanden und der Post Restore Node Modules Step cached nach erfolgreicher Ausführung der Pipeline automatisch unsere zuvor installierten Abhängigkeiten. In diesem Fall waren noch keine Daten im Cache vorhanden. Doch wenn wir nun neue Änderungen pushen können wir sehen dass die Abhängigkeiten aus dem Cache geladen werden und das Installieren der Abhängigkeiten selbst anschliessend nur noch wenige Sekunden dauert:

Durch das Laden von Daten aus dem Cache dauert das Installieren von Abhängigkeiten nur noch wenige Sekunden.

Fazit

Wie du gesehen hast lässt sich mit GitHub Actions innerhalb kürzester Zeit eine CI/CD Pipeline einrichten, die eine Static Site baut und deployed. Das Ganze ist selbstverständlich nicht auf Static Sites limitiert. Du kannst mit GitHub Actions so gut wie alles automatisieren dass du auch manuell machen kannst. Als Ziel solltest du dir dabei immer setzen, die Pipeline so optimiert wie möglich zu gestalten. Jede unnötige Sekunde Laufzeit ist eine zu viel. Nur so stellst du sicher, dass deine Pipeline auch in Zukunft, wenn deine Anwendung wächst, noch schnell genug ist und du von den Vorteilen von CI/CD profitierst.