Files
pipelines/README.md
T
jklein 0f7cd04c70 SonarQube-Analyse als geteilten Baustein aufnehmen
Neuer Baustein sonar-analysis.yml, on: workflow_call. Ein Waechter fuer die
Voraussetzungen des Aufrufers, Checkout mit vollstaendiger Historie, dann die
offizielle Scan-Action. Beide Actions auf einen Commit gepinnt.

Projektspezifisches steht in der sonar-project.properties des aufrufenden
Repositories, nicht hier - damit ist der Workflow fuer jedes Repository
identisch und ein zweiter Abnehmer kostet eine Datei mit drei Zeilen.

Kein Quality-Gate-Schritt, und das absichtlich: Die Community Build analysiert
nur den Hauptzweig, der Ausloeser liegt damit nach dem Merge, und dort ist
nichts mehr zu blockieren. Ein rotes main ohne Wirkung nimmt die
Glaubwuerdigkeit der uebrigen Pruefungen mit. Die zwei Bedingungen, unter denen
ein Gate richtig waere, stehen im README und als Kommentar an der Stelle, an
der der Schritt stuende.

SONAR_HOST_URL ist eine Variable des Aufrufers, SONAR_TOKEN sein Secret. Die
Adresse bleibt als Variable im Protokoll lesbar - erst das macht eine falsche
Adresse diagnostizierbar. Der Waechter prueft beide, bevor er abbricht, damit
ein Repository ohne beide das in einem Lauf erfaehrt.

Beide Eingaben sind type: string, auch project-base-dir: Boolesche
workflow_call-Eingaben kommen auf dieser Gitea-Version nicht an (Issue #4), und
die Regel gilt fuer jede Eingabe hier, damit sie nicht je Datei neu entschieden
wird.

Die Langform - warum fetch-depth: 0 nicht optional ist, warum kein
Docker-Runner noetig ist, was der Scanner zur Laufzeit holt und was an diesem
Baustein noch nicht gemessen ist - steht im Wiki unter sonar-analysis.

Der Baustein ist ab v4 verfuegbar; die Tag-Nummer bezeichnet wie bisher den
Zustand des Repositories und nicht die Version einer Datei.
2026-10-01 23:33:05 +00:00

396 lines
19 KiB
Markdown

# pipelines
Geteilte CI/CD-Bausteine für die Gitea-Instanz `git.jk-dev.de`. Die Workflows
hier werden nicht selbst ausgelöst, sondern von anderen Repositories über
`uses:` aufgerufen (`on: workflow_call`).
Diese Datei ist die **Kurzreferenz für Aufrufer**: was ein Baustein tut, seine
Eingaben, ein Aufrufbeispiel, die Voraussetzungen. Wer einen Aufruf schreibt,
braucht nur das. Die Langform — warum die Dinge so sind und was dafür gemessen
wurde — steht im [Wiki](https://git.jk-dev.de/workflow-util/pipelines/wiki),
eine Seite je Baustein und eine für die Eigenheiten dieser Forge.
## Bausteine
### `.gitea/workflows/docker-build-push.yml`
Baut ein Docker-Image und schiebt es als Multi-Arch-Manifest
(`linux/amd64,linux/arm64`) in eine Registry, vorgabegemäß `git.jk-dev.de`.
Läuft auf einem Runner mit dem Label `ubuntu-docker`.
Gepusht wird unter fünf Tags:
```
<registry>/<image-owner>/<container-name>:latest
<registry>/<image-owner>/<container-name>:<version-number>
<registry>/<image-owner>/<container-name>:<major>
<registry>/<image-owner>/<container-name>:<major>.<minor>
<registry>/<image-owner>/<container-name>:<major>.<minor>.<patch>
```
Der Checkout holt Submodule mit; die Zugangsdaten dafür und für die Registry
kommen zur Laufzeit aus 1Password Connect.
Mit `push: 'false'` entfällt der Push — der Baustein baut dann nur. Damit nutzt
die CI eines Projekts dieselbe Bau-Logik wie das Deployment, statt sie zu
duplizieren. Siehe [Prüfmodus](#prüfmodus-ohne-push).
#### Eingaben
| Eingabe | Pflicht | Vorgabe | Bedeutung |
| ---------------------- | ------- | ------------------------- | --------- |
| `container-name` | ja | — | Name des Images |
| `dockerfile` | ja | — | Pfad zum Dockerfile |
| `docker-context` | ja | — | Build-Kontext |
| `image-owner` | ja | — | Eigentümer-Segment des Images, also der mittlere Teil von `<registry>/<owner>/<name>` |
| `push` | nein | `'true'` | Zeichenkette `'true'` oder `'false'`. Ob das Image gepusht wird. Bei `'false'` werden keine Zugangsdaten aus 1Password geholt und es findet kein Registry-Login statt — siehe [Prüfmodus](#prüfmodus-ohne-push) |
| `registry` | nein | `git.jk-dev.de` | Registry, für die getaggt und — beim Push — bei der angemeldet wird |
| `version-number` | nein | `0.0.0` | Vollständige Version, wird als Tag gesetzt |
| `version-number-major` | nein | `0` | Major-Anteil für die mitwandernden Tags |
| `version-number-minor` | nein | `0` | Minor-Anteil |
| `version-number-patch` | nein | `0` | Patch-Anteil |
| `timeout-minutes` | nein | `45` | Harte Grenze für den Build-Job |
| `platforms` | nein | `linux/amd64,linux/arm64` | Zielplattformen der Manifest-Liste |
| `build-args` | nein | — | Zusätzliche Build-Argumente, ein `NAME=wert` je Zeile |
Dass `image-owner` Pflicht ist und `registry` nicht, ist Absicht: Der Eigentümer
ist je Projekt verschieden und soll nie stillschweigend geraten werden — genau
das war der Fehler des früher fest verdrahteten `suer`, unter dem die Images
aller Projekte landeten. Die Registry ist dagegen praktisch immer dieselbe.
`push` ist eine Zeichenkette und kein Boolean — und das ist Absicht. Auf dieser
Gitea-Version (gemessen an 1.27.0) werden **boolesche `workflow_call`-Eingaben
nicht an den aufgerufenen Workflow durchgereicht**: Weder ihre Vorgabe noch ein
ausdrücklich übergebenes `push: true` kamen an, `inputs.push` blieb leer, und
der Baustein baute immer im Prüfmodus — bei grünem Lauf. Alle Eingaben vom Typ
`string` kamen im selben Lauf korrekt an. Details in
[Issue #4](https://git.jk-dev.de/workflow-util/pipelines/issues/4).
Deshalb bitte immer in Anführungszeichen schreiben: `push: 'true'` bzw.
`push: 'false'`. Ein unquotiertes `push: false` ist im YAML ein Boolean und
wird verschluckt.
> **Brechende Änderung gegenüber `v1`.** Aufrufer, die `push` als YAML-Boolean
> übergeben, müssen auf `@v2` wechseln und dabei auf die Schreibweise mit
> Anführungszeichen umstellen. Wer `@v1` weiterverwendet, behält das alte
> Verhalten samt Fehler.
Die vier Versionsangaben dienen ausschließlich der Tag-Liste. Sie sind keine
Pflicht mehr, damit ein Prüflauf keine Platzhalter erfinden muss; wer pusht,
übergibt sie wie bisher. Ihre Vorgabe ist bewusst `0.0.0` und nicht leer — die
Tag-Liste wird auch ohne Push gebildet, und ein leerer Wert ergäbe das ungültige
`…/<name>:`.
#### Aufrufbeispiel
```yaml
jobs:
image:
uses: workflow-util/pipelines/.gitea/workflows/docker-build-push.yml@v2
with:
version-number: 2026.09.1
version-number-major: '2026'
version-number-minor: '09'
version-number-patch: '1'
container-name: bk-dev-website
dockerfile: ./Dockerfile
docker-context: .
image-owner: bekemeyer-klein
push: 'true'
secrets: inherit
```
`secrets: inherit` ist notwendig — der Workflow braucht `OP_CONNECT_TOKEN` aus
dem aufrufenden Repository.
#### Prüfmodus ohne Push
Mit `push: 'false'` baut der Baustein nur und veröffentlicht nichts. Es werden
dann keine Zugangsdaten aus 1Password geholt und es findet kein Registry-Login
statt. Die CI eines Projekts nutzt damit dieselbe Bau-Logik wie das Deployment,
statt sie zu duplizieren.
Weder Secrets noch Versionsangaben sind dafür nötig:
```yaml
jobs:
build-check:
uses: workflow-util/pipelines/.gitea/workflows/docker-build-push.yml@v2
with:
container-name: bk-dev-website
dockerfile: ./Dockerfile
docker-context: .
image-owner: bekemeyer-klein
push: 'false'
```
Die Anführungszeichen sind nicht kosmetisch: `push: false` ohne sie ist ein
YAML-Boolean und kommt im Baustein nicht an (siehe oben). Es griffe dann die
Vorgabe `'true'`, der Lauf versuchte also trotz `push: false` zu
veröffentlichen — sofern Gitea die Typabweichung nicht ohnehin als Fehler
zurückweist.
> **Private Submodule funktionieren in diesem Modus nicht.**
> Der Checkout-Token stammt aus demselben 1Password-Schritt, der ohne Push
> übersprungen wird. Der Checkout fällt deshalb auf den Standard-Token des Jobs
> zurück. Der genügt für das ausführende Repository, nicht aber für die
> Submodule in eigenen privaten Repositories — deren Klon scheitert mit
> `Repository not found`. Projekte **mit** privaten Submodulen können
> `push: false` daher nicht verwenden; für Projekte ohne Submodule ist der
> Modus unproblematisch.
`secrets: inherit` kann hier entfallen. Es zu belassen schadet nicht — der
Workflow greift ohne Push auf kein Secret zu.
#### Voraussetzungen im aufrufenden Repository
| Name | Art | Zweck |
| ------------------ | -------- | ----- |
| `OP_CONNECT_HOST` | Variable | Adresse des 1Password-Connect-Servers im Cluster. Nur beim Push nötig |
| `OP_CONNECT_TOKEN` | Secret | Zugang zu diesem Connect-Server. Nur beim Push nötig |
| `DOCKER_HOST` | Variable | Nur nötig, wenn der Runner-Daemon rootless läuft und sein Socket nicht unter `/var/run/docker.sock` liegt (dann z. B. `unix:///run/user/1000/docker.sock`). Ist die Variable nicht gesetzt, nutzt Docker seinen Default. |
Die eigentlichen Zugänge für Checkout und Registry liegen in 1Password
(`GitHub-Actions/jlab-git-checkout-token`, `GitHub-Actions/jlab-registry-access`)
und müssen in Gitea nicht hinterlegt werden.
### `.gitea/workflows/publish-helm-chart.yml`
Packt ein Helm-Chart und lädt es in eine **klassische, indexbasierte**
Helm-Registry. Das ist kein OCI-`helm push`: Es gibt keine Registry-Sitzung,
sondern ein einzelnes HTTP-`POST` des gepackten `.tgz`, je Anfrage
authentifiziert. Läuft auf `ubuntu-latest`.
Die Ziel-URL wird zusammengesetzt, nicht gespeichert:
```
<charts-url>/<registry-owner>/helm/api/charts
```
`<charts-url>` ist die Basis bis `/api/packages` und liegt in 1Password
(`GitHub-Actions/jlab-registry-access`, Feld `charts-url`), `<registry-owner>`
ist die Pflichteingabe. Damit steht im aufrufenden Repository kein Wert, der auf
die Registry zeigt — nicht einmal ein Pfadsegment —, und ein zweiter Eigentümer
auf derselben Instanz braucht keine zweite Einstellung.
#### Eingaben
| Eingabe | Pflicht | Vorgabe | Bedeutung |
| ---------------- | ------- | ------- | --------- |
| `chart-name` | ja | — | Name des Charts, zugleich der Name seines Verzeichnisses unterhalb von `chart-location` |
| `registry-owner` | ja | — | Eigentümer-Segment der Ziel-URL, also der `<owner>` in `<basis>/<owner>/helm/api/charts` |
| `chart-location` | nein | `.` | Verzeichnis, in dem das Chart-Verzeichnis liegt |
Dass `registry-owner` Pflicht ist und keine Vorgabe hat, folgt derselben
Hausregel wie `image-owner` oben: Ein Eigentümer-Segment wird nie geraten.
Aufrufer übergeben `${{ github.repository_owner }}`; der Wert wird damit im
**aufrufenden** Repository ausgewertet, wo er gemessen ist (`jlab-cluster`, Lauf
282 von `validate-charts.yml`). Dass der Kontext auch in einem von fremd
aufgerufenen Workflow der des Aufrufers bleibt, ist dokumentiert, auf dieser
Gitea-Fassung aber nicht gemessen — und ein Chart, das unter dem falschen
Eigentümer veröffentlicht wird, ist nichts, wofür man sich auf eine
Kontext-Semantik verlassen müsste. Als Eingabe hängt der Baustein an keiner.
Alle Eingaben sind `type: string`, auch dort, wo künftig ein Schalter
hinzukommt: Boolesche `workflow_call`-Eingaben werden auf dieser Gitea-Version
nicht durchgereicht — bei grünem Lauf. Siehe
[Issue #4](https://git.jk-dev.de/workflow-util/pipelines/issues/4) und die
Begründung im Abschnitt zu `push` oben.
#### Ausgaben
| Ausgabe | Bedeutung |
| --------------- | --------- |
| `chart-version` | Die veröffentlichte Chart-Version, gelesen aus `version:` in `Chart.yaml` |
#### Aufrufbeispiel
```yaml
jobs:
publish-outline:
name: Publish Outline Helm Chart
uses: workflow-util/pipelines/.gitea/workflows/publish-helm-chart.yml@v3
with:
chart-name: 'outline'
registry-owner: ${{ github.repository_owner }}
secrets: inherit
```
`secrets: inherit` ist notwendig — der Workflow braucht `OP_CONNECT_TOKEN` aus
dem aufrufenden Repository.
Der Baustein löst sich nicht selbst aus. Wann veröffentlicht wird, entscheidet
die Hülle im aufrufenden Repository über ihr eigenes `on:` — üblicherweise ein
`push` auf `main`, eingeschränkt auf das Chart-Verzeichnis, dazu ein
`workflow_dispatch` für den Fall, dass von Hand nachgeschoben werden muss.
#### Form des Tresor-Felds `charts-url`
Das Feld enthält **nur** die Basis:
```
https://<host>/api/packages # so
https://<host>/api/packages/<owner>/helm/api/charts # nicht so
```
Drei Wächter weisen eine andere Form namentlich zurück: wenn der Wert auf
`/api/charts` endet, wenn er das Segment `/helm` enthält, und wenn er nicht auf
`/api/packages` endet. Der Grund ist die Lesbarkeit des Fehlers — 1Password
maskiert die URL im Protokoll, ein 404 gegen eine maskierte Adresse wäre nicht zu
deuten. Der Eigentümer wird aus demselben Grund geprüft: Ein leerer Wert ergäbe
`<basis>//helm/api/charts`, also wieder einen nackten 404.
#### Voraussetzungen im aufrufenden Repository
| Name | Art | Zweck |
| ------------------------- | ----------------- | ----- |
| `OP_CONNECT_HOST` | Variable | Adresse des 1Password-Connect-Servers im Cluster, etwa `https://op-connect.example.com` |
| `OP_CONNECT_TOKEN` | Secret | Zugang zu diesem Connect-Server, kommt über `secrets: inherit` |
| `ci/helm-dependencies.sh` | Datei im Repository | Ausführbares Skript, das die deklarierten Abhängigkeiten eines Charts nach `charts/` holt |
`OP_CONNECT_HOST` ist bewusst eine Variable des aufrufenden Repositories und
kein fest verdrahteter Wert: Ein geteilter Baustein trägt nicht die
Infrastruktur-Adresse eines einzelnen Mandanten. Der Preis dafür ist eine
Voraussetzung, die man vergessen kann, und eine nicht gesetzte Variable kommt als
leere Zeichenkette an. Deshalb prüft der erste Schritt sie und benennt beim
Abbruch die Einstellung samt Ort — statt zwei Schritte später an einer leeren
Adresse zu scheitern.
`ci/helm-dependencies.sh` ist ein Skript des **aufrufenden** Repositories, und
das bleibt nach dem Umzug hierher so: Der Checkout holt das Repository, zu dem
der Lauf gehört — das des Aufrufers —, also ist `${{ github.workspace }}` dessen
Arbeitsverzeichnis. Der Aufruf steht vor `helm package`, weil `helm package` mit
`found in Chart.yaml, but missing in charts/ directory` abbricht, solange eine
deklarierte Abhängigkeit nicht geholt ist. Für ein Chart ohne `dependencies:`
tut das Skript nichts, vorhanden und ausführbar muss es trotzdem sein; fehlt es,
sagt der Lauf genau das.
Der Registry-Zugang selbst liegt in 1Password
(`GitHub-Actions/jlab-registry-access`: `username`, `password`, `charts-url`) und
muss in Gitea nicht hinterlegt werden.
### `.gitea/workflows/sonar-analysis.yml`
Schickt den Baum des aufrufenden Repositories durch eine **SonarQube-Analyse**.
Der Baustein checkt mit vollständiger Historie aus und ruft dann die offizielle
Scan-Action auf; alles Projektspezifische — Projektschlüssel, Ausschlüsse,
Quellpfade — steht in der `sonar-project.properties` des aufrufenden
Repositories, nicht hier. Läuft auf `ubuntu-latest`.
**Die Analyse ist kein Torwächter.** Es gibt bewusst keinen
Quality-Gate-Schritt: Auf der SonarQube Community Build wird nur der Hauptzweig
analysiert, der Auslöser liegt damit **nach** dem Merge, und dort ist nichts
mehr zu blockieren. Ein rotes Gate färbte nur `main` rot, und einen Dauerzustand
lernt man zu ignorieren. Sinnvoll wird es erst, wenn PR-Analyse verfügbar ist
oder ein Deployment an diesem Workflow hängt — bis dahin ist ein Gate hier eine
Verschlechterung und keine vergessene Zeile. Ein Befund ist eine Beobachtung des
Zustands, und wer ihn sehen will, schaut in SonarQube.
#### Eingaben
| Eingabe | Pflicht | Vorgabe | Bedeutung |
| ------------------ | ------- | ------- | --------- |
| `args` | nein | — | Zusätzliche Argumente für den Sonar-Scanner-CLI, unverändert an die Scan-Action weitergegeben. Für einen einmaligen Diagnoselauf, etwa `-Dsonar.verbose=true` |
| `project-base-dir` | nein | `.` | Verzeichnis, in dem die Analyse beginnt (`sonar.projectBaseDir`); die `sonar-project.properties` wird relativ dazu gesucht |
Beide Eingaben sind `type: string`. Das ist kein Versehen: Boolesche
`workflow_call`-Eingaben kommen auf dieser Gitea-Version nicht an — bei grünem
Lauf. Siehe
[Issue #4](https://git.jk-dev.de/workflow-util/pipelines/issues/4) und die
Begründung im Abschnitt zu `push` oben.
Die Vorgabe von `project-base-dir` ist `.` und nicht leer, weil die Scan-Action
selbst `.` als Vorgabe hat: Ein leerer Wert würde durchgereicht und überschriebe
diese Vorgabe, statt sie stehen zu lassen.
#### Aufrufbeispiel
```yaml
name: SonarQube
on:
push:
branches: [main]
jobs:
sonar:
uses: workflow-util/pipelines/.gitea/workflows/sonar-analysis.yml@v4
secrets: inherit
```
`secrets: inherit` ist notwendig — der Workflow braucht `SONAR_TOKEN` aus dem
aufrufenden Repository.
Der Baustein löst sich nicht selbst aus. Wann analysiert wird, entscheidet die
Hülle im aufrufenden Repository über ihr eigenes `on:` — üblicherweise ein
`push` auf `main`, weil die Community Build ausschließlich den Hauptzweig
analysiert.
#### Voraussetzungen im aufrufenden Repository
| Name | Art | Zweck |
| ------------------------- | ------------------- | ----- |
| `SONAR_HOST_URL` | Variable | Adresse des SonarQube-Servers, etwa `https://sonar.example.com` — ohne Pfad dahinter |
| `SONAR_TOKEN` | Secret | Token mit dem Recht, Analysen auszuführen; soll das Projekt beim ersten Lauf selbst entstehen, zusätzlich das Recht zum Anlegen. Kommt über `secrets: inherit` |
| `sonar-project.properties` | Datei im Repository | Trägt mindestens `sonar.projectKey`. Der Schlüssel sollte den Eigentümer enthalten (`<owner>_<repo>`), weil die Instanz mehrere Eigentümer bedient und ein nackter Repository-Name beim zweiten kollidiert |
Dass die Adresse eine **Variable** ist und kein Secret, ist Absicht: Sie ist
kein Zugangsdatum, und als Variable bleibt sie im Protokoll lesbar — erst das
macht eine falsche Adresse überhaupt diagnostizierbar.
Beides gehört dem aufrufenden Repository, nicht diesem Baustein — ein geteilter
Baustein trägt weder die Infrastruktur-Adresse eines einzelnen Mandanten noch
dessen Token. Der Preis sind zwei Voraussetzungen, die man vergessen kann, und
eine nicht gesetzte Variable kommt als leere Zeichenkette an. Deshalb prüft der
erste Schritt beide und benennt beim Abbruch die Einstellung samt Ort; geprüft
wird vollständig, bevor abgebrochen wird, damit ein Repository ohne beide das in
einem Lauf erfährt und nicht in zwei.
Für `SONAR_TOKEN` fängt der Wächter allerdings nur den Fall eines leeren oder
nicht durchgereichten Secrets. Ein **ganz fehlendes** kommt nie so weit: Der
Workflow deklariert es als `required`, und ein fehlendes Pflicht-Secret lässt auf
dieser Instanz die Job-Erzeugung scheitern — der Lauf steht dann ohne Schritte
da. Das ist hier der verständliche Fall, denn ohne Token gibt es keine Analyse;
nachzulesen im Wiki unter
[Gemessenes-Verhalten](https://git.jk-dev.de/workflow-util/pipelines/wiki/Gemessenes-Verhalten).
## Auf einen Tag zeigen, nicht auf `@main`
Aufrufe bitte immer an einen Tag binden:
```yaml
uses: workflow-util/pipelines/.gitea/workflows/docker-build-push.yml@v2 # so
uses: workflow-util/pipelines/.gitea/workflows/docker-build-push.yml@main # nicht so
```
Mit `@main` ändert jede Anpassung hier unangekündigt das Verhalten sämtlicher
Projekte — und der Fehler zeigt sich dann dort, wo niemand etwas geändert hat.
Ein Tag macht das Übernehmen einer neuen Fassung zu einer bewussten Entscheidung
im jeweiligen Projekt.
Änderungen hier bekommen deshalb einen neuen Tag. Bricht eine Änderung
bestehende Aufrufe, wird der Major-Anteil erhöht.
`v2` ist genau so ein Fall: Die Umstellung von `push` auf `type: string` bricht
Aufrufe, die den Wert als YAML-Boolean übergeben. `v1` bleibt unverändert
stehen; wer umstellt, wechselt auf `@v2` und schreibt `push: 'true'` bzw.
`push: 'false'`.
### Die Nummer bezeichnet den Zustand des Repositories
Tags gelten hier **repository-weit**, nicht je Datei. `v3` ist der Stand, ab dem
es `publish-helm-chart.yml` gibt, `v4` der Stand, ab dem es
`sonar-analysis.yml` gibt; unter `v1` und `v2` existiert keine der beiden
Dateien, ein Aufruf auf `@v2` findet sie also nicht.
Das hat eine Folge, die man kennen muss, weil sie sonst als Fehler gelesen wird:
Wer künftig `docker-build-push.yml` ändert, erhöht auf `v5` — und ein Aufruf von
`publish-helm-chart.yml@v3` bleibt gültig und richtig, obwohl sich an dieser
Datei nichts geändert hat. Die Nummer ist keine Dateiversion. Umgekehrt zwingt
eine brechende Änderung an einem Baustein die Aufrufer der anderen zu nichts:
Sie bleiben auf ihrem Tag stehen, bis sie bewusst nachziehen.
`v3` und `v4` sind beide additiv. Sie nehmen je einen Baustein hinzu und ändern
an den vorhandenen nichts; bestehende Aufrufer auf `@v2` bzw. `@v3` bleiben
unberührt.