Von CGI Zu Forgejo Action

Da ich vor kurzen die Action in Forgejo gefunden habe, musste ich diese nun direkt ausprobieren. Und das automatisierte erstellen meiner Webseite, dass aktuell über hooks und CGI durchgeführt wurde und immer wieder Probleme macht, war das ideale Testobjekt.

Installation

Bei Go-Programmen sehe ich den Nutzen von docker nicht, daher habe ich den forgejo-runner auch diesmal direkt als einzelne Datei heruntergeladen und als Service in meinen Server hinterlegt. Zum installieren und aktualisieren verwende ich das Script , mit folgenden Konfigurationen:

target_architecture="linux-amd64"
prog_installdir="/usr/local/bin"

git_server="code.forgejo.org"
git_repo="forgejo/runner"

prog_link_name="ci-runner"
prog_service="ci-runner.service"

Einrichten

Beim einrichten des Runners, habe ich mich sehr an die originale Dokumentation von forgejo gehalten. Diese findet man hier .

Wichtig dabei ist, dass ein eigener Benutzer für den Runner erzeugt wird. Unter diesen liegen dann die Konfigurationen und er ist entsprechend für die Läufe abgeschottet. Die Konfigurationsdatei kann direkt von Runner mit dem nachfolgenden Aufruf erzeugt werden und im Home-Verzeichnis abgelegt werden:

ci-runner generate-config > /home/runner/config.yml

Sinnvoll ist die Implementierung als systemd-Dienst, dafür kann das Script am Ende genommen werden. Meine Variante schaut wie folgt aus, wobei mein Benutzername runner benannt wurde:

[Unit]
Description=Forgejo Runner
Documentation=https://forgejo.org/docs/latest/admin/actions/
After=gitea.service

[Service]
ExecStart=ci-runner --config /home/runner/config.yml daemon
ExecReload=/bin/kill -s HUP $MAINPID

# This user and working directory must already exist
User=runner
WorkingDirectory=/home/runner
Restart=on-failure
TimeoutSec=0
RestartSec=10

[Install]
WantedBy=mutli-user.target

Registrieren

Zum registrieren des Runners muss zuerst ein Runner erstellt werden. Die einfachste Variante ist das über das Einstellungsmenü, welches auf der linken Seite unter dem Menüpunkt Action den Untermenüpunkt Runner besitzt. Dort wird nun ein neuer Runner erstellt und die Informationen können nun direkt in der Konfigurationsdatei hinterlegt werden, wie es in der offiziellen Doku [hier][FCIR] beschrieben ist.

Die Registrierung über die Kommandozeile am runner kann weiterhin verwendet werden, aber ist aufwendiger und würde ich bei einer neuen Installation auch nicht mehr durchführen.

Repository

Zuerst sollte das Repository überprüft werden. Hierbei ist wichtig, dass die Submodule so verlinkt sind, dass der Server auf dem der Runner läuft entsprechenden Zugriff besitzt. Am einfachsten ist dies, wenn die Verlinkung über das http-Protokoll durchgeführt wird. Bei ssh müssten die known_hosts-Einträge hinterlegt werden und ein privater Schlüssel für den Zugriff.

Um die Konfiguration direkt zu testen, kann der nachfolgende Befehl ausgeführt werden:

git clone --recurse-submodule https://gitea.example.com/myname/myproject
ci-runner exec -c ~/config.yml -i -self-hosted

Der Clone-Befehl kann ignoriert werden, wenn das Repository auf dem aktuellen Rechner vorliegt.

Konfiguration

Die Konfiguration für den Runner wird direkt im Repository abgelegt. Dafür wird die Verzeichnisstruktur .forgejo/workflows erzeugt. In diese werden die .yml-Dateien abgelegt. Hier können je nach Bedarf auch mehr als eine Datei erstellt werden.

Vorbereitungen

Um die Scripts von lokalen Gegebenheiten frei zu halten, gibt es Variablen und Geheiminisse die entweder direkt am Projekt definiert werden können, oder global für den kompletten Server. Für beide müssen die jeweiligen Einstellungen aufgerufen werden. Auf der linken Seite gibt es den Menüpunkt Actions unter dem die Unterpunkte für die Variablen und Geheimnisse liegen.

Unter Varialben können Werte wie Servernamen oder Verzeichnisse hinterlegt werden. Diese Werte können von jedem mit Zugriff angeschaut und verändert werden. Der Zugriff in den Scripten wird mit dem Präfix vars. gesteuert.

Die Geheimnisse sind für sinnvoll für private Schlüssel oder Passwörter. Diese Werte können außerhalb der Scripte nicht ausgelesen werden sondern nur noch verändert werden. Der Zugriff in den Scripten wird mit dem Präfix secret. gesteuert.

Der Anfang

Der Anfang dieser Datei schaut wie folgt aus:

on:
  push:
    branches:
      - main
  pull_request:
  workflow_dispatch:

jobs:
  buildanddeploy:
    name: ${{ matrix.builds.name }}-site - build and deploy
    runs-on: self-hosted
    strategy:
      matrix:
        builds:
          - name: develop
            folder: ${{ vars.DEV_FOLDER }}
            buildarg: -D -E -F
            createcommit: yes
          - name: production
            folder: ${{ vars.PROD_FOLDER }}
            buildarg: --minify
            createcommit: yes
    steps:
      - ...

Mit dem Schlüsselwort on wird definiert auf welche Aktion hin, dieser Auftrag ausfgeführt werden soll. In diesem Fall wird die Aktion ausgeführt, wenn auf den Branch main neue Daten gepusht werden.

Mit dem Schlüsselwort jobs werden die Aufträge und ihre Schritte definiert. Zuerst wird der Name des Auftrags angegeben. Dieser Fall verwendet eine Ersetzungs-Variable, da hier ein Mehrfach-Build definiert ist, der durch die strategy/matrix/builds Definition mit Werten gefüllt wird. Der Eintrag runs-on definiert auf welchen Typ von Runner der Auftrag ausgeführt werden soll. Bei den Mehrfach-Bilds ist darauf zu achten, dass die gleichen Parameter vergeben werden, diese werden hier verwendet, um zum einen die Ziel-Ordner unterschiedlich anzugeben. Ebenso wird es genutzt um die unterschiedlichen Aufrufparameter zur Erstellung der Website zu definieren. In meinen Fall verwende ich es um eine Hauptseite und eine Testseite, in der alle Drafts mit erzeugt werden, zu erstellen und zu publischen.

Mit steps wird der Ablauf innerhalb des Auftrags definiert, auf welchen im Nachfolgenden eingegangen wird.

Auschecken

Der erste Schritt ist häufig das Clonen der Quellen vom Source-Code, welches mit folgenden Schritt definiert wird:

      - uses: actions/checkout@v4
        with:
          submodules: true  # Fetch Hugo themes (true or recursive)
          fetch-depth: 0    # Fetch all history for .GitInfo and Lastmod

Hugo installieren (falls notwendig)

Die Programme Go und Hugo werden durch die Packet-Verwaltung des Server installiert und auf dem aktuellen Stand gehalten. Dadurch entfallen diese Aktionen in meinen Skript. Sollte das bei euch nicht so sein, dann müsste ihr die Aktion peaceiris/actions-hugo@v3 noch zusätzlich nach dem Checkout und for dem Erstellen einfügen, wie hier zu sehen:

      - name: Setup Hugo
        uses: peaceiris/actions-hugo@v3
        with:
          hugo-Version: 'latest'
          extended: true

Zusätzliche Infos erzeugen

Der Schritt definiert das aktuelle Datum als Ausgabe-Wert, welcher im nachfolgenden Script verwendet werden kann. Dafür muss der Schritt mit der hinterlegten id angesprochen werden. In diesem Fall würde der Zugriff so aussehen “${{ steps.date.outputs.date }}”.

Da ich gerne den aktuellen Commit-Hash auf meiner Seite noch mit anzeigen möchte und kann, gibt es dafür einen eigenen Schritt, der diesen in die dafür vorgesehene Datei abgelegt.

      - name: Get current date
        id: date
        run: echo "date=$(date +'%Y%m%d%H%M%S')" >> $GITHUB_OUTPUT
      - name: Create commit id
        if: matrix.builds.createcommit == 'yes'
        run: |
          echo "Set Commit-ID to ${{ github.SHA }}"
          echo "${{ github.SHA }}" > commit_id.txt

Webseite erstellen

Nun wir die Webseite erstellt. Hier wird auch mit den entsprechenden Ersetzungsvariablen aus dem Matrix-Build gearbeitet damit die unterschiedlichen Definitionen gebaut werden können:

      - name: build
        run: |
          echo "Build site with ${{ matrix.builds.buildarg }}"
          hugo ${{ matrix.builds.buildarg }}

Upload

Nun müssen die erstellten Webseiten noch auf dem Webserver hochgeladen werden. Hierfür kann ich 2 Möglichkeiten vorstellen.

Upload über FTP

Für den Upload mit ftp, wird hier eine netrc-Datei erzeugt. Somit ist eine Verbindung ohne Passwort-Eingabe möglich. Da hier das Passwort im Klartext hinterlegt wird, wird im Anschluss die Datei sofort wieder entfernt.

      - name: setup ftp
        run: |
          touch ~/.netrc
          chmod 0600 ~/.netrc
          echo "machine ${{ vars.WEBSERVER_FTP }}" > ~/.netrc
          echo "login ${{ vars.WEBSERVER_FTP_LOGIN }}" > ~/.netrc
          echo "password ${{ secrets.WEBSERVER_FTP_PASSWORD }}" > ~/.netrc
      - name: push public
        run: |
          ftp ${{ vars.WEBSERVER_FTP }} <<EOF
          binary
          mkdir ${{ matrix.builds.folder }}
          cd ${{ matrix.builds.folder }}
          mput ./public/*
          bye
          EOF
      - name: cleanup
        run: rm ~/.netrc

Das ablegen in eigene Unterordner pro Build wär hier auch angedacht. Da eine Verlinkung im Nachgang über ftp nicht möglich war, ist die einzige Möglichkeit den Upload direkt in das Hauptverzeichnis durchzuführen.

Upload über SFTP

Und hier ein Beispiel wie es über Secure FTP durchgeführt werden kann.

Hierfür wurde zuerst das aktuelle Datum ermittelt und zurückgegeben, damit dieser Wert im Nachgang für die Erstellung der dynamischen Zielordner verwendet werden kann.

Der zweite Schritt ist das vorbereiten für die SSH Verbindung. Hierbei wird das hinterlegte Geheimniss als privater Schlüssel zur Anmeldung erzeugt, und der externe SSH-Server und sein Öffentlicher Schlüssel als bekannter Computer hinterlegt.

Im dritten Schritt werden nun die Dateien in den dynamischen Ordner hochgeladen. Danach wird der symlink neu verknüpft, wodurch die Downtime minimal gehalten wird.

Im letzten Schritt werden dann zur Sicherheit noch die ssh-Schlüssel wieder entfernt.

Eine Verwendung von scp hat leider nicht funktioniert, da diese keine Zielordner beim Upload erzeugen kann. Da dies nur via sftp funktioniert, habe ich mich dazu entschlossen nun alles direkt über dies zu implementieren.

      - name: Get current date
        id: date
        run: echo "date=$(date +'%Y%m%d%H%M%S')" >> $GITHUB_OUTPUT
      - name: setup ssh
        run: |
          echo "Generate PrivKey und KnownHost-Entry for ${{ vars.SERVER_NAME }}"
          mkdir -p ~/.ssh
          chmod 0700 ~/.ssh
          echo "${{ secrets.SERVER_SFTP_PRIVKEY }}" > ~/.ssh/id_ed25519
          chmod 0600 ~/.ssh/id_ed25519
          echo "${{ vars.SERVER_NAME }} ${{ vars.SERVER_SSH_PUBKEY }}" >> ~/.ssh/known_hosts
          chmod 0600 ~/.ssh/known_hosts
      - name: push public and update symlink
        run: | # scp erzeugt keine Zielordner, daher direkt alles via sftp erstellen und kopieren (symlink erstellt nur wenn noch nicht vorhanden)
          echo "Copy files to ${{ matrix.builds.folder }}${{ steps.date.outputs.date }} and symlink to ${{ matrix.builds.folder }}"
          sftp -i ~/.ssh/id_ed25519 -P 2222 ${{ vars.SERVER_SFTP_LOGIN }}@${{ vars.SERVER_NAME }} <<EOF
          mkdir ${{ matrix.builds.folder }}${{ steps.date.outputs.date }}
          cd ${{ matrix.builds.folder }}${{ steps.date.outputs.date }}
          put -r ./public/.
          cd ..
          rm ${{ matrix.builds.folder }}
          symlink ${{ matrix.builds.folder }}${{ steps.date.outputs.date }} ${{ matrix.builds.folder }}
          EOF
      - name: cleanup ssh
        run: rm ~/.ssh/*

Einen Nachteil bei der Variante mit dem symlink gibt es aber schon und zwar stapeln sich nun die Freigaben im Zielverzeichnis. Dies kommt daher, dass das entfernen eines kompletten Ordners via sftp nicht möglich ist. Die einfache Lösung für dieses Problem wäre ein Shell-Script, dass die Ordner aufräumt, welches über einen Cron-Job gerufen wird.

Upload mit rsync

Hier wäre noch ein Beispiel mit rsync welches ich im Nachgang gefunden habe. Hierbei wird gezeigt, wie die SSH-Schlüssel ohne die Erstellung der ID verwendet werden können. Und der Publc-Key wird direkt vom Server ermittelt, was aus dem Aspekt der Sicherheit nicht so sinnvoll ist.

      - name: Deploy
        run: |
          eval $(ssh-agent -s)
          echo "${{ secrets.SERVER_SFTP_PRIVKEY }}" | ssh-add -
          apt update && apt install -y rsync
          mkdir ~/.ssh
          ssh-keyscan "${{ vars.SERVER_NAME }}"  > ~/.ssh/known_hosts
          rsync -atv --progress ./public/ ${{ vars.SERVER_SFTP_LOGIN }}@${{ vars.SERVER_NAME }}:${{ matrix.builds.folder }}