No description
  • C# 98.8%
  • Dockerfile 1.2%
Find a file
Christian 30bdc13921
All checks were successful
Build / build (push) Successful in 47s
README.md aktualisiert
2026-09-02 14:18:15 +00:00
.forgejo/workflows .forgejo/workflows/build.yml aktualisiert 2026-09-02 13:24:31 +00:00
.vscode DWD-Wetter-App als Demo-Last für die Runner-Pipeline 2026-09-01 21:06:25 +02:00
ci ci/Dockerfile aktualisiert 2026-09-02 13:45:19 +00:00
DwdWetter DWD-Wetter-App als Demo-Last für die Runner-Pipeline 2026-09-01 21:06:25 +02:00
DwdWetter.Tests DWD-Wetter-App als Demo-Last für die Runner-Pipeline 2026-09-01 21:06:25 +02:00
.gitignore DWD-Wetter-App als Demo-Last für die Runner-Pipeline 2026-09-01 21:06:25 +02:00
DwdWetter.slnx DWD-Wetter-App als Demo-Last für die Runner-Pipeline 2026-09-01 21:06:25 +02:00
README.md README.md aktualisiert 2026-09-02 14:18:15 +00:00

CI-Demo_DWD-Wetter

Eine kleine ASP.NET-Anwendung holt aktuelle Messwerte des Deutschen Wetterdienstes für Berlin und zeichnet sie im ASCII-Art-Stil, angelehnt an wttr.in.

Die App ist Mittel zum Zweck. Der eigentliche Gegenstand dieses Repos ist die Pipeline: ein selbst betriebener Forgejo-Runner, der bei jedem Push in einem eigens gebauten Container baut.

Stand: Die Pipeline klont, baut, testet und publiziert den Native-AOT-Build im Container. Offen sind die beiden anderen Publish-Profile und die Auslieferung der Artefakte als Release.


Funktionsprinzip

git.duske.it  (Forgejo, rootless Podman auf anderem Host)
    │
    │  Runner fragt per Poll: "Arbeit für Label 'docker'?"
    ▼
┌─────────── Debian (Build-Host) ───────────────────────────┐
│                                                            │
│  forgejo-runner.service   (systemd, User: runner)          │
│         │  startet pro Job einen Container                 │
│         ▼                                                  │
│  Docker-Daemon ──► Container aus ci-trixie-dotnet-10       │
│                       └─ dotnet-sdk-10.0, git, clang       │
│                          hier laufen die Steps             │
└────────────────────────────────────────────────────────────┘

Kernidee: Der Runner selbst arbeitet nicht. Er holt den Job ab, startet dafür einen Container und lässt die Steps darin laufen. Das Debian ist nur Wirt für den Runner-Prozess und den Docker-Daemon, nicht die Ausführungsumgebung. Nach dem Job wird der Container samt Netzwerk gelöscht.

Wer stattdessen direkt auf dem Host bauen will, nimmt das Backend host (siehe Host-Modus als Alternative).


Die Datenquelle

Der DWD betreibt keine REST-API. Auf die Frage nach einer Schnittstelle antwortet er in den eigenen FAQ, dass zurzeit keine dedizierte API angeboten wird. Was es gibt, ist ein Open-Data-Server mit Rohdaten.

Diese Demo benutzt die SYNOP-Bodenwettermeldungen im JSON-Format:

https://opendata.dwd.de/weather/weather_reports/synoptic/germany/json/

Das sind echte Messwerte der DWD-Stationen, seit 2018 zusätzlich zum BUFR-Format auch als JSON verfügbar.

Warum nicht Brightsky: Brightsky liefert bequemes JSON auf denselben DWD-Daten, ist aber ein von einer Privatperson freiwillig betriebenes Projekt. Für dieses Repo war eine offizielle Stelle Bedingung. Wer die Bequemlichkeit will, findet Brightsky unter brightsky.dev; die Nutzungsbedingungen des DWD gelten für die Daten weiterhin.

Was beim Hinsehen herauskam

Alle folgenden Angaben stammen aus dem echten Datenbestand, nicht aus Beispielen im Netz.

Zwei Sorten Dateien, nicht eine. Der Server legt alle 5–8 Minuten eine Datei ab, aber es sind zwei verschiedene Dinge:

Beispiel gepackt entpackt Inhalt
MW_370 8 KB 0,9 MB Teillieferung, kein Berlin
MW_378 13 KB 1,1 MB Teillieferung, nur Dahlem
MW_372 424 KB 87 MB Volllieferung, alle Stationen
MW_376 639 KB 133 MB Volllieferung, alle Stationen

Der latest-Alias ist unbrauchbar. Er zeigt auf die zuletzt abgelegte Datei, und das ist meistens eine Teillieferung ohne Berlin. Die ursprüngliche Annahme, latest genüge und der Berlin-fehlt-Fall sei die Ausnahme, war falsch — er wäre der Normalfall gewesen. Die App liest deshalb das Verzeichnis-Listing und nimmt daraus die neueste Datei oberhalb einer Größenschwelle von 150 KB. Das sind genau die Volllieferungen.

Die Berliner Stationen. Verifiziert, nicht geraten:

Station WMO meldet
Berlin-Dahlem(FU) 10381 vollen Satz
Berlin-Tempelhof 10384 vollen Satz
Berlin Brandenburg 10385 vollen Satz
Berlin-Buch — nur Temperatur und Feuchte
Berlin-Marzahn — nur Temperatur und Feuchte

Die letzten beiden sind automatische Stationen ohne WMO-Nummer. Wer nur über die Kennung sucht, findet sie nicht — und es gibt Volllieferungen, in denen Berlin ausschließlich durch diese beiden vertreten ist. Die App sucht deshalb über den Stationsnamen und arbeitet die Liste in dieser Reihenfolge ab: die vorderen Stationen zuerst, die beiden hinteren als Rückfall.

Die Struktur. Das Format entspricht der Ausgabe von bufr_dump -js, einem maschinellen Dump einer meteorologischen Binärstruktur:

{ "messages": [ meldung, meldung, ... ] }

meldung = [ {key,value}, {key,value}, ..., [ subset, subset, ... ] ]
                └─ Kopfdaten                  └─ je ein Subset pro Station

subset  = [ {key,value}, [ {key,value}, [ {key,value}, [ ... ] ] ] ]
             └─ tief rechts-verschachtelt, bis zu 150 Ebenen

Die Verschachtelung trägt keine Information. Es zählt allein die Reihenfolge der Key/Value-Paare, und ein neues subsetNumber beginnt eine neue Station. Damit lässt sich die Datei als flacher Token-Strom lesen, statt einen Baum zu bauen. Die Feldnamen sind die der WMO-Tabellen: airTemperature in Kelvin, pressureReducedToMeanSeaLevel in Pascal, presentWeather als Code, dazu stationOrSiteName, blockNumber, stationNumber.

bz2 ohne externes Werkzeug. System.IO.Compression kann Gzip und Deflate, aber kein Bzip2. Die naheliegende Lösung — bzip2 im Container aufrufen — scheitert am Windows-Build: dort gibt es das Werkzeug nicht, und ein Self-Contained-Binary, das ein externes Programm braucht, ist keines mehr. Die App benutzt deshalb SharpZipLib und entpackt im Prozess. Ins Dockerfile muss dafür nichts.


Die App

Ein Endpunkt, ein Zweck:

GET /

Antwortet mit HTML. Kein Auth, keine Datenbank, keine Konfiguration, kein Swagger.

Wetterbericht: Berlin-Tempelhof (WMO 10384)

               Bewoelkt
     .--.      21.1 °C   Taupunkt 12.1 °C
  .-(    ).    5.4 m/s aus W (270°)
 (___.__)__)   56 % rel. Feuchte
               1017.9 hPa

Beobachtet: 01.09.2026 18:30 UTC

Ablauf pro Request: Listing holen → neueste Volllieferung wählen → herunterladen → im Prozess entpacken → streamend parsen → beste Berliner Station → HTML. Dauert rund 4 Sekunden, davon etwa 1 Sekunde für das Parsen der 69–133 MB.

Zustandslos. Jeder Aufruf geht denselben Weg von vorn. Kein Cache, kein Hintergrund-Refresh, kein zuletzt bekannter Wert. Zwei Requests kurz nacheinander lesen beide das Listing neu.

Streamend geparst. Eine entpackte Volllieferung ist bis zu 133 MB groß. Als Objektbaum im Speicher wäre das ein Vielfaches davon, und im AOT-Container erst recht unschön. Der Parser läuft mit Utf8JsonReader über einen 64-KB-Puffer und hält immer nur die eine Station, die er gerade liest. Der Speicherbedarf hängt nicht von der Dateigröße ab.

Minimal API statt Controller. Native AOT verträgt keine MVC-Controller, weil die per Reflection gebunden werden. Für einen einzigen Endpunkt ist die Minimal API ohnehin die passendere Bauform. CreateSlimBuilder lässt alles weg, was hier nicht gebraucht wird, und EnableRequestDelegateGenerator im csproj erzeugt die Endpunkt-Anbindung zur Compilezeit.

Aufbau

DwdWetter/
  Program.cs        Endpunkt und DI, sieben Zeilen Substanz
  DwdClient.cs      Listing lesen, Datei wählen, holen, entpacken
  SynopParser.cs    Token-Strom lesen, Station heraussuchen
  AsciiArt.cs       Wettercode zu Bild
  HtmlPage.cs       Bild und Messwerte nebeneinander setzen
  Observation.cs    eine Beobachtung, alle Werte optional

DwdWetter.Tests/
  Fixtures/         gekürzte echte DWD-Meldungen

Der Netzzugriff steckt hinter IWeatherSource. Nur deshalb laufen die Tests ohne Internet.


Bauen und Ausliefern

Drei Wege, je ein Publish Profile unter DwdWetter/Properties/PublishProfiles/. Alle drei erzeugen ein einzelnes Binary; auf dem Zielsystem muss kein .NET installiert sein.

# Profil Ergebnis Größe Start
1 Linux-NativeAot natives ELF, kein .NET 11,8 MB 72–95 ms
2 Linux-SelfContained SingleFile + ReadyToRun 122 MB ~305 ms
3 Windows-SelfContained DwdWetter.exe 123 MB nicht gemessen
dotnet publish DwdWetter/DwdWetter.csproj -p:PublishProfile=Linux-NativeAot
dotnet publish DwdWetter/DwdWetter.csproj -p:PublishProfile=Linux-SelfContained
dotnet publish DwdWetter/DwdWetter.csproj -p:PublishProfile=Windows-SelfContained

Die Ausgabe landet unter DwdWetter/bin/Release/<Profilname>/.

Weg 1, Native AOT, ist der interessante. Kein JIT, keine Runtime im Gepäck: der Compiler erzeugt direkt Maschinencode. Ein Zehntel der Größe und ein Viertel der Startzeit gegenüber Weg 2. Der Preis ist, dass Reflection weitgehend wegfällt — was hier nichts kostet, weil die App keine braucht. IsAotCompatible steht im csproj und gilt für alle Builds, damit ein AOT-Problem nicht erst im AOT-Profil auffällt.

Weg 3 lässt sich auf Linux bauen (Cross-Compilation), aber dort nicht ausführen. Der Build ist durchgelaufen und erzeugt die .exe samt web.config; dass sie auf Windows startet, ist ungetestet.

Laufnummer aus der CI

Die csproj nimmt eine Build-Nummer entgegen:

<BuildNumber Condition="'$(BuildNumber)' == ''">dev</BuildNumber>
<InformationalVersion>1.0.$(BuildNumber)</InformationalVersion>
dotnet publish ... -p:BuildNumber=${{ github.run_number }}

Ohne Angabe steht 1.0.dev im Binary.


Tests

dotnet test

16 Tests, alle grün, keiner braucht Netz. Sie prüfen die drei Stellen, an denen tatsächlich etwas schiefgehen kann:

  • Der Parser gegen gekürzte, echte DWD-Meldungen: Station finden, Kelvin und Pascal umrechnen, und — die eigentliche Fehlerquelle — Stationen sauber an subsetNumber trennen, statt Werte der Vorgängerstation durchsickern zu lassen. Dazu der Fall "keine Berliner Station gemeldet".
  • Die Wetterlage aus dem BUFR-Code 020003, inklusive der Verschiebung um 100 bei automatischen Stationen und des Rückfalls auf den Bedeckungsgrad. Außerdem, dass alle ASCII-Bilder gleich viele Zeilen gleicher Breite haben — sonst franst die rechte Spalte aus.
  • Der Endpunkt über WebApplicationFactory mit vorgegebener Beobachtung, einmal mit Daten und einmal ohne.

Die Fixtures unter DwdWetter.Tests/Fixtures/ sind echte Meldungen vom Open-Data-Server, auf zwei Meldungen gekürzt.


Entwickeln

Die Projektmappe ist DwdWetter.slnx — das neue, schlanke XML-Format, das dotnet seit .NET 9 versteht. dotnet build und dotnet test im Repo-Root finden sie von allein.

Unter .vscode/ liegt eine fertige Konfiguration für VSCodium mit der Erweiterung muhammad-sammy.csharp (dem lizenzfreien Fork von ms-dotnettools.csharp). Der Fork liefert statt des proprietären vsdbg den offenen netcoredbg mit; der Anmeldetyp in launch.json heißt trotzdem weiterhin coreclr.

Startkonfigurationen (F5):

Name Zweck
DwdWetter starten Debug-Build, öffnet den Browser auf Port 5199, sobald der Server meldet
Native-AOT-Binary starten publiziert per Task und startet das native Binary auf Port 5200
An Prozess anhaengen für einen bereits laufenden Prozess
Tests: anhaengen siehe unten

Tasks (Strg+Shift+B für den Build):

build, test, test (wartet auf Debugger) sowie je eine Task pro Publish-Profil.

Tests debuggen braucht zwei Schritte, weil die Tests in einem Kindprozess laufen und ein Debugger am dotnet test-Aufruf dort nichts sieht:

  1. Task test (wartet auf Debugger) starten. Sie setzt VSTEST_HOST_DEBUG=1; der Testhost hält an und nennt seine PID im Terminal.
  2. Startkonfiguration Tests: anhaengen ausführen und diese PID auswählen.

Das Projekt hat bewusst keine launchSettings.json. Die Adresse steht direkt in der jeweiligen Startkonfiguration.


Bausteine

Ort Rolle
/usr/local/bin/forgejo-runner Binary, v13.1.0
/etc/systemd/system/forgejo-runner.service Dienst-Definition, läuft als User runner
/home/runner/runner-config.yml Konfiguration, vor allem die Labels
.runner (Pfad aus der Config) Registrierungs-Identität: UUID + dauerhaftes Token. Secret.
ci/Dockerfile → ci-trixie-dotnet-10 Build-Umgebung, lokal gebaut, in keiner Registry
.forgejo/workflows/*.yml im Repo Was gebaut wird. Erzeugt die Jobs.
DwdWetter/ die App
DwdWetter/Properties/PublishProfiles/ die drei Auslieferungswege
DwdWetter.slnx Projektmappe, von dotnet build und dotnet test genutzt
.vscode/ Debugger und Tasks für VSCodium

Das Label-System

Das ist der Angelpunkt des ganzen Aufbaus. Ohne Label bekommt der Runner niemals einen Job, und Forgejo meldet das nicht.

docker : docker:// ci-trixie-dotnet-10:latest
  │         │            │
  │         │            └─ Image
  │         └─ Backend: "docker" = Container, "host" = direkt auf der Maschine
  └─ Name. Frei wählbar. Darauf zeigt "runs-on:" im Workflow.

Der Name links und das Backend rechts heißen beide zufällig docker, haben aber nichts miteinander zu tun. kantine:docker://ci-trixie-dotnet-10 mit runs-on: kantine wäre identisch und lesbarer.

In /home/runner/runner-config.yml:

runner:
  labels: ["docker:docker://ci-trixie-dotnet-10:latest"]

Die ausführliche Mapping-Form aus den Config-Kommentaren ist dieselbe Sache in anderer Schreibweise. Sie lohnt erst, wenn mehrere Optionen dazukommen (?platform=linux/amd64 und ähnliches).

Forgejo bekommt nur den Namen gemeldet, nicht das Image. Im Log steht deshalb with labels: [docker], und in der Weboberfläche steht als Label docker. Wo der Job tatsächlich landet, entscheidet allein der Runner.


Erstinstallation

1) Docker

sudo apt install docker.io
sudo usermod -aG docker $USER
sudo usermod -aG docker runner

Danach neu anmelden und den Dienst neu starten:

sudo systemctl restart forgejo-runner

Ein Neustart der Shell allein reicht nicht: Die Gruppenzuordnung wird beim Prozessstart gelesen, der laufende Daemon kennt sie also noch nicht.

Prüfen, und zwar für beide User:

docker ps
sudo -u runner docker ps

Eine leere Tabelle mit Kopfzeile ist das gewünschte Ergebnis: Der Client hat den Daemon erreicht und eine Antwort bekommen.

Warum docker.io aus Debian und nicht das offizielle Repo: Ein Befehl, keine GPG-Schlüssel, Sicherheitsupdates über den normalen Kanal. Die Docker-Version ist für den Runner egal, er benutzt seit Jahren stabile API-Aufrufe. Wer docker compose als Subkommando braucht, landet später doch beim offiziellen Repo.

Warum nicht Podman: Der Runner spricht die Docker-API über den Socket an. Podman kann die emulieren, aber für Actions-Workloads ist diese Kombination kaum erprobt: Man hängt an einer Kompatibilitätsschicht, zu der es im Fehlerfall wenig Erfahrungsberichte gibt. Auf einem RHEL-artigen Zielsystem, wo rootless Podman der Standard ist, sieht die Rechnung anders aus.

2) Build-Image bauen

ci/Dockerfile:

FROM debian:trixie
RUN apt update \
    && apt install wget -y \
    && wget https://packages.microsoft.com/config/debian/13/packages-microsoft-prod.deb -O packages-microsoft-prod.deb \
    && dpkg -i packages-microsoft-prod.deb \
    && rm packages-microsoft-prod.deb \
    && apt update \
    && apt install dotnet-sdk-10.0 -y \
    && apt install git -y \
    && apt install clang -y
docker build -t ci-trixie-dotnet-10:latest ci/
docker run --rm ci-trixie-dotnet-10:latest dotnet --version

Es liegt unter ci/, nicht im Repo-Root. Ein Dockerfile im Root liest sich wie "damit wird die App gebaut", und das stimmt nicht: Es baut die Build-Umgebung, nicht die Anwendung.

Der Punkt am Ende ist das Kontext-Verzeichnis, nicht Dekoration. Alles, was per COPY ins Image soll, muss darunter liegen.

Warum ein eigenes Image statt eines fertigen dotnet-Images: bewusste Entscheidung. Der Inhalt ist damit vollständig bekannt und im Dockerfile nachlesbar.

Das Image liegt nur lokal. Damit der Runner es nicht in einer Registry sucht, muss force_pull aus sein. Im Job-Log ist das an forcePull=false zu sehen.

clang ist für den Native-AOT-Build nötig; ohne ihn bricht der Link-Schritt ab. zlib1g-dev nicht — nachgemessen, siehe Learnings. bzip2 ebenfalls nicht, die App entpackt im Prozess. nodejs erst, sobald uses:-Actions zum Einsatz kommen, siehe actions/checkout und Node.

3) systemd-Unit

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

[Service]
ExecStart=/usr/local/bin/forgejo-runner daemon -c /home/runner/runner-config.yml
ExecReload=/bin/kill -s HUP $MAINPID
User=runner
WorkingDirectory=/home/runner
Restart=on-failure
TimeoutStopSec=infinity
RestartSec=10

[Install]
WantedBy=multi-user.target

Der User runner und /home/runner müssen vorher existieren. Der Runner legt unter $HOME/.cache Dateien an, ein Home mit /nonexistent bricht ab.

4) Labels setzen und starten

sudo systemctl restart forgejo-runner
sudo journalctl -u forgejo-runner -n 5

Erwartete Zeile:

runner: CzEwo Runner, with version: v13.1.0, with labels: [docker], ephemeral: false, declared successfully

Steht dort with labels: [], ist die Config nicht angekommen. Dann liegt das labels: entweder nicht unter runner:, oder die .runner-Datei gewinnt.

5) Workflow im Repository

Pfad exakt so, ohne führende oder eingestreute Leerzeichen, und workflows im Plural:

.forgejo/workflows/build.yml
name: Build
on: [push]

jobs:
  build:
    runs-on: docker
    steps:
      - run: |
          git clone https://token:${{ secrets.GITHUB_TOKEN }}@git.duske.it/Christian/CI-Demo_DWD-Wetter.git \
          && cd CI-Demo_DWD-Wetter \
          && dotnet build \
          && dotnet test \
          && dotnet publish DwdWetter/DwdWetter.csproj -p:PublishProfile=Linux-NativeAot

runs-on muss dem Namen des Labels entsprechen, nicht dem Image.

Der Dateiname erscheint in der Weboberfläche als Filter (build.yml), der name: darin als Überschrift des Laufs. Beide sollten dasselbe meinen.


Betrieb / Alltag

sudo systemctl restart forgejo-runner
sudo systemctl status  forgejo-runner
sudo journalctl -u forgejo-runner -f

Nach jeder Änderung an runner-config.yml ist ein Restart nötig. Nach jedem docker build mit gleichem Tag genügt es, den nächsten Job zu starten, der Runner nimmt das neue Image.

Übersicht über alle Läufe der Instanz, inklusive der Zuordnung zum Runner:

https://git.duske.it/admin/actions/runners/1

Unter "Letzte Aufgaben auf diesem Runner" steht, welche Repos tatsächlich Jobs erzeugt haben. Das ist die verlässlichste Stelle, um "läuft überhaupt etwas?" zu beantworten.


Privates Repository klonen

Forgejo erzeugt pro Lauf ein Token, das nirgends angelegt werden muss. Es ist nur während des Jobs gültig, gilt nur für dieses Repo und wird im Log maskiert. Es liegt nicht unter Einstellungen → Actions → Geheimnisse, dort stehen nur selbst eingetragene Secrets.

- run: |
    git clone --branch feature/runner-test \
      https://token:${{ secrets.GITHUB_TOKEN }}@git.duske.it/Christian/CI-Demo_DWD-Wetter.git
    cd CI-Demo_DWD-Wetter
    dotnet build

FORGEJO_TOKEN ist ein Alias mit identischem Wert. Der Name GITHUB_TOKEN existiert, damit übernommene Workflows ohne Anpassung laufen.

Der senkrechte Strich macht aus dem Block ein mehrzeiliges Shell-Skript in einer gemeinsamen Shell, deshalb wirkt cd über die Zeile hinaus.

Noch nicht verifiziert. Dieser Schritt ist bislang nicht durchgelaufen.

Repo-URL vs. Git-URL

https://git.duske.it/Christian/CzEwo  /src/branch/  feature/runner-test
        └─ Server    └─ Owner/Repo    └─ nur Web    └─ Branch

Der mittlere Teil ist Browser-Navigation. Git will die Repo-Adresse plus --branch als eigenes Argument.


actions/checkout und Node

Fast alle uses:-Actions sind JavaScript-Programme. Der Runner braucht dafür eine Node-Runtime im Container. Ein Image ohne Node lässt eigene run:-Zeilen problemlos laufen, bricht aber beim ersten uses: ab.

Zwei Wege:

  • nodejs mit ins Dockerfile aufnehmen und actions/checkout@v4 benutzen
  • ohne Actions arbeiten und selbst git clone aufrufen

Was checkout abnimmt: flacher Klon statt voller Historie, automatische Nutzung des Job-Tokens, und vor allem das Auschecken genau des Commits, der den Lauf ausgelöst hat. Ein eigenes git clone holt immer den aktuellen Branch-Stand. Wird zwischen Push und Build nachgepusht, baut man etwas anderes als erwartet.


Host-Modus als Alternative

Für ein Setup, in dem der Server ohnehin nur diesen einen Build macht, ist der Container-Weg unnötiger Aufwand:

runner:
  labels: ["debian:host"]

Kein Backend-Ziel, kein Image. Die Steps laufen direkt im Debian, alle Werkzeuge werden einmal per apt installiert und bleiben liegen.

Container Host
Abhängigkeiten im Dockerfile, versionierbar einmal per apt auf dem Host
Aufwand pro Änderung Image neu bauen apt install
Sauberkeit jeder Job startet frisch Jobs hinterlassen Spuren
Reproduzierbarkeit gegeben unklar, ob es woanders auch baut
Docker nötig ja nein

Troubleshooting

Symptom Ursache / Fix
with labels: [] im Log Labels fehlen oder stehen nicht unter runner:. Ohne Label wird nie ein Job zugeteilt.
Runner steht auf "Inaktiv", grüner Punkt Normalzustand. Er meldet sich, hat gerade nichts zu tun.
Repo zeigt "Es existieren noch keine Workflows" Pfad prüfen. Byteweise, nicht im Browser (siehe Learnings).
Job bleibt in "wartend" hängen runs-on passt zu keinem Label eines erreichbaren Runners.
Job bricht bei uses: ab Kein Node im Image.
docker pull schlägt fehl bei lokalem Image force_pull in der Runner-Config abschalten.
Runner plötzlich offline Dienst prüfen: sudo systemctl status forgejo-runner.
Erster Job dauert 30s, danach 2s Normal. Der erste Lauf zieht das Image, danach liegt es im Cache.
Seite zeigt "Keine aktuellen Daten für Berlin" Kommt vor. Nicht jede Volllieferung führt eine Berliner Station.
Platform linker ('clang' or 'gcc') not found C-Toolchain fehlt im Image. clang nachinstallieren, Image neu bauen.

Learnings

Ein Tabulator im Pfad, und Forgejo schweigt

Das kostete den größten Teil der Zeit. Beim Anlegen der Datei über die Weboberfläche waren aus einem Copy-Paste unsichtbare Zeichen in den Verzeichnisnamen gerutscht. Der tatsächliche Pfad lautete:

" \t.forgejo/workflows/build.yml"

Sichtbar wurde das ausschließlich in der Adresszeile des Browsers:

/src/branch/master/%20%09.forgejo/workflows
                   └─ %20 = Leerzeichen, %09 = Tabulator

Die Breadcrumb zeigte sauber .forgejo / workflows, weil der Browser die Zeichen nicht darstellt. Forgejo vergleicht den Pfad zeichengenau, " \t.forgejo" ist für den Server ein normales Verzeichnis mit seltsamem Namen. Es gab keinen Workflow, also keinen Lauf, also keine Fehlermeldung.

Erst der Vergleich zweier Commits nebeneinander machte es sichtbar:

.forgejo/workflows      ← korrekt
.forgejo / workflows    ← kaputt

Konsequenz: Bei "Datei ist da, Forgejo sieht sie nicht" zuerst den Pfad byteweise prüfen, nicht die Konfiguration:

git ls-tree -r master --name-only | grep -i forgejo | cat -A

Sackgassen, die geprüft und ausgeschlossen wurden

Der Reihe nach durchprobiert, alle ergebnislos. Steht hier, damit sie nicht nochmal drankommen:

Vermutung Befund
Actions-Modul im Repo deaktiviert War aktiv. Einstellungen → Einheiten → Übersicht. Wäre es aus, fehlte der Actions-Reiter komplett.
Falscher Standardbranch (main statt master) master war korrekt eingetragen.
Repo ist ein Spiegel, Pushes lösen nichts aus Kein Spiegel. Steht sonst als Markierung neben dem Repo-Namen.
Actions global in app.ini aus Seit Forgejo v1.21 standardmäßig an. Der erste Testlauf hatte das ohnehin bewiesen.
Workflow muss erst im Standardbranch existieren Nie bestätigt. Die Datei lag zu dem Zeitpunkt am falschen Pfad, der Test war wertlos. Offene Frage.
API-Abfrage /api/v1/repos/.../contents/... als Gegenprobe Untauglich. Liefert im Browser auch für das funktionierende Repo "Ziel konnte nicht gefunden werden".

Was tatsächlich weitergeholfen hätte: sofort https://git.duske.it/admin/actions/runners/1 aufrufen. Dort steht, welche Repos je einen Job erzeugt haben. Stand dort nichts vom fraglichen Repo, war klar, dass das Problem vor der Job-Zuteilung liegt und der Runner unschuldig ist.

Der Container ist nicht der Host

Naheliegender Denkfehler beim Image-Tag: Auf dem Host läuft Trixie, also müsste auch das Image Trixie sein. Falsch. Der Container bringt eine eigene Distribution mit, eigenes apt, eigenes /usr. Geteilt wird allein der Kernel des Hosts.

Ebenso: apt install im Job funktioniert, ist aber sinnlos. Der Container wird nach dem Job gelöscht, die Installation stirbt mit ihm und läuft beim nächsten Push von vorn. Deshalb gehört sie ins Dockerfile.

apt-get update gehört in dieselbe RUN-Zeile

Jedes RUN ist eine eigene Schicht mit eigener Shell. Der Build-Cache greift auf Zeilenebene. Steht update in einer eigenen Zeile, nimmt Docker beim nächsten Build die alte Paketliste aus dem Cache und installiert gegen veraltete Indizes. Aus demselben Grund wirkt cd nicht über Zeilengrenzen, dafür gibt es WORKDIR.

Gruppenmitgliedschaft in docker ist Root

Wer den Socket ansprechen kann, startet einen Container mit gemountetem Host-Dateisystem. Auf einer Wegwerf-Testmaschine egal, auf einem echten Server nicht.

Die .runner-Datei

JSON, entsteht bei register, enthält UUID und ein dauerhaftes Token, das aus dem einmaligen Registrierungstoken abgeleitet wurde. Damit meldet sich der Daemon bei jedem Start neu an, deshalb registriert man nur einmal.

Sie ist ein Secret und gehört nicht ins Repository. Wird sie gelöscht, muss neu registriert werden, und der alte Eintrag bleibt als Karteileiche in der Weboberfläche stehen.

Die Labels stehen ebenfalls darin. Bleiben sie nach einer Config-Änderung leer, ist das der zweite Ort zum Nachschauen.

"Offizielle Quelle" heißt nicht "bequeme Quelle"

Die Erwartung, eine Bundesbehörde biete eine JSON-REST-API, ist falsch. Der DWD stellt Rohdaten bereit, in Formaten, die aus der Meteorologie kommen und nicht aus dem Web. Wer eine handliche API will, landet zwangsläufig bei einem Drittanbieter, und der ist dann nicht mehr die offizielle Stelle.

Genau diese Reibung ist für eine Build-Demo brauchbar: Es gibt echte Arbeit zu tun, statt nur einen HTTP-Aufruf abzusetzen.

Erst in die Daten schauen, dann entscheiden

Drei Annahmen über das DWD-Format haben sich am echten Datenbestand als falsch herausgestellt, und jede hätte eine Fehlentscheidung nach sich gezogen:

  • "latest genügt, Berlin fehlt nur selten" — falsch. latest zeigt fast immer auf eine Teillieferung ohne Berlin. Die Seite hätte meistens nichts angezeigt.
  • "Die Dateien sind ein paar hundert Kilobyte groß" — das ist die gepackte Größe. Entpackt sind es bis zu 133 MB. Als Objektbaum im Speicher wäre das im AOT-Container unangenehm geworden.
  • "Berliner Stationen findet man über die WMO-Nummer" — Berlin-Buch und Berlin-Marzahn haben keine. Genau das hat beim ersten Live-Lauf zu einem leeren Ergebnis geführt, obwohl Berlin in der Datei stand.

Der Aufwand dafür war eine halbe Stunde Herumstochern in einer echten Datei, vor der ersten Zeile Code.

Fehler einzeln kommen lassen

Der AOT-Publish wurde bewusst ohne die fehlenden Pakete gestartet, statt sie vorher zu installieren. Der Grund: Die Vermutung, es brauche gcc und zlib1g-dev, stand zwar im README, war aber von einer anderen Maschine übernommen. Der echte Fehler nannte dann clang oder gcc und sagte nichts über zlib.

Das Muster gilt allgemein für diese Art Fehler: Sie kommen gestaffelt. Der Linker meldet sich erst, wenn kompiliert ist, die Header erst, wenn der Linker läuft. Wer alles auf Verdacht vorinstalliert, erfährt nie, was tatsächlich nötig war, und schleppt Pakete mit, die niemand braucht.

Nachgemessen wurde auch der zweite Teil: ein Image mit clang allein, ohne zlib1g-dev, und derselbe Publish-Lauf. Lief fehlerfrei durch. Die zlib-Header waren nie nötig, sie standen nur in einer von einer anderen Maschine übernommenen Vermutung. Eine Variable geändert, eine Frage beantwortet.

Das gilt für dieses Projekt. Käme eine Abhängigkeit dazu, die gegen zlib linkt, wäre der Befund hinfällig.

Derselbe Gedanke wie bei den DWD-Daten: erst hinschauen, dann entscheiden.

Verschachtelung, die keine Information trägt

Das bufr_dump -js-Format verschachtelt jedes Key/Value-Paar eine Ebene tiefer als das vorige, bis zu 150 Ebenen weit. Das sieht nach einem Baum aus, den man nachbauen müsste. Ist es aber nicht: die Schachtelung ist ein Artefakt der BUFR-Struktur, und für das Auslesen zählt allein die Reihenfolge der Paare. Wer das erkennt, liest die Datei als flachen Token-Strom mit konstantem Speicher, statt 133 MB in einen Objektbaum zu heben.


Offene Punkte

App

  • Bedeckungsgrad und Wettercode gemeinsam auswerten. Aktuell entscheidet der Code, und nur wenn er "nichts Nennenswertes" meldet, der Bedeckungsgrad.
  • Kein Rückwärtslaufen durchs Verzeichnis: enthält die neueste Volllieferung kein Berlin, meldet die Seite das und gut. Bewusste Entscheidung gegen mehrere 133-MB-Durchläufe pro Request.

Auslieferung

  • Der Windows-Build ist cross-compiliert, aber nie auf Windows gestartet.
  • Ob die App als systemd-Unit oder Windows-Service läuft, ist ungetestet. Es gibt bislang keine Installationsanleitung.

Pipeline

  • Nur das AOT-Profil wird gebaut. Self-Contained Linux und Windows fehlen im Workflow.
  • Build-Artefakte als Release veröffentlichen. Bislang liegen sie im Container und werden mit ihm gelöscht. Der Weg dahin: release.yml mit on: push: tags:, das Release über die Forgejo-API per curl anlegen und das Binary als Asset anhängen. Keine Action nötig, also auch kein nodejs im Image. Eine Schleife entsteht dabei nicht: Ein Release verändert den Git-Baum nicht, es gibt also keinen Push, auf den on: [push] reagieren könnte.
  • -p:BuildNumber=${{ github.run_number }} ist vorgesehen, aber noch nicht im Workflow.
  • on: [push] löst bei jeder Änderung aus, auch bei einer README-Zeile. Spätestens wenn alle drei Profile gebaut werden, lohnt eine Trennung in ci.yml (jeder Push) und release.yml (nur Tags).
  • nodejs ins Dockerfile, falls actions/checkout@v4 benutzt werden soll.
  • Klären, ob ein Workflow im Standardbranch existieren muss, damit Feature-Branches Läufe erzeugen.