diff --git a/.gitea/workflows/docker-build-push.yml b/.gitea/workflows/docker-build-push.yml new file mode 100644 index 0000000..4fe5005 --- /dev/null +++ b/.gitea/workflows/docker-build-push.yml @@ -0,0 +1,222 @@ +on: + workflow_call: + inputs: + # Die vier Versionsangaben dienen ausschliesslich der Tag-Liste. Ohne Push + # gibt es nichts zu taggen, deshalb sind sie nicht mehr Pflicht — ein + # reiner Prueflauf muesste sonst Platzhalter erfinden. Bestehende Aufrufer + # uebergeben sie unveraendert weiter; required von true auf false zu + # senken ist rueckwaertskompatibel. + # + # Die Vorgabe ist 0 bzw. 0.0.0 und nicht etwa leer: docker/build-push-action + # gibt die Tag-Liste auch ohne Push als --tag an den Build weiter. Ein + # leerer Wert ergaebe "//:" und liesse den Build an + # "invalid reference format" scheitern. 0.0.0 ist ein gueltiger, sichtbar + # unechter Wert. + version-number: + required: false + default: 0.0.0 + type: string + version-number-major: + required: false + default: '0' + type: string + version-number-minor: + required: false + default: '0' + type: string + version-number-patch: + required: false + default: '0' + type: string + push: + description: >- + Whether the built image is pushed. With push: false no credentials are + fetched from 1Password and no registry login happens, so the workflow + doubles as a build check. Caveat: the checkout token comes from the + same 1Password step, so without push the checkout falls back to the + job token — which cannot clone private submodules. + required: false + default: true + type: boolean + container-name: + required: true + type: string + dockerfile: + required: true + type: string + docker-context: + required: true + type: string + # image-owner und registry bilden zusammen das Praefix des Images. Dass + # das eine Pflicht ist und das andere nicht, ist Absicht: der Eigentuemer + # ist je Projekt verschieden und soll nie stillschweigend geraten werden — + # genau das war der Fehler des frueheren, fest verdrahteten "suer", unter + # dem die Images aller Projekte landeten. Die Registry ist dagegen + # praktisch immer dieselbe und darf deshalb eine Vorgabe haben. + image-owner: + description: >- + Owner segment of the pushed image, i.e. the middle part of + //. Used to be hardcoded to "suer", + which sent every image of every calling project to the same account. + Deliberately has no default: every caller states its owner explicitly. + required: true + type: string + registry: + description: >- + Registry the image is tagged for and, when pushing, logged in to. + required: false + default: git.jk-dev.de + type: string + timeout-minutes: + description: 'Hard limit for the build job. Without one a wedged build runs into the 6h default.' + required: false + default: 45 + type: number + platforms: + description: >- + Target platforms of the pushed manifest list. The build stages of all + dockerfiles are pinned to the build platform, so an extra platform only + adds its runtime stage, not another compile run. + required: false + default: linux/amd64,linux/arm64 + type: string + build-args: + description: >- + Extra build arguments for the image, one NAME=value per line. The docs + image uses it to stamp the released version into writerside.cfg. + required: false + default: '' + type: string + +name: build and push Docker Image + +# Einen env-Block auf Workflow-Ebene gibt es nicht mehr: sein einziger Eintrag +# war REGISTRY, und der ist jetzt eine Eingabe. Dort haette er auch nicht +# bleiben koennen — env auf Workflow-Ebene wird ausgewertet, bevor es einen +# inputs-Kontext gibt. Registry und Eigentuemer werden deshalb an den beiden +# Stellen gelesen, an denen der Kontext sicher vorhanden ist: im +# Login-Schritt und in der Tag-Liste. + +jobs: + build-image: + name: Build ${{ inputs.container-name }} Container + permissions: + packages: write + contents: read + runs-on: [ ubuntu-docker ] + timeout-minutes: ${{ inputs.timeout-minutes }} + env: + # Ohne Angabe suchen Buildx und die Docker-CLI unter + # unix:///var/run/docker.sock. Laeuft der Daemon des Runners rootless, + # liegt sein Socket stattdessen unter /run/user//docker.sock. Der + # Pfad kommt deshalb aus einer Repository-Variablen; ist sie nicht + # gesetzt, bleibt die Variable leer und Docker nutzt seinen Default. + DOCKER_HOST: ${{ vars.DOCKER_HOST }} + steps: + # Der Token, den Gitea jedem Job stellt, taugt fuer zwei Dinge nicht: + # Er gilt nur fuer das ausfuehrende Repository, waehrend die Submodule in + # eigenen, privaten Repositories liegen — deren Klon scheitert an + # "Repository not found". Und die Container-Registry lehnt ihn ab + # (container.Authenticate antwortet mit 401). Beides deckt je ein eigener + # Zugang ab, gepflegt in 1Password und hier zur Laufzeit ueber den + # Connect-Server im Cluster geholt; in Gitea liegt nur dessen Zugang. + # + # Ohne Push werden gar keine Zugangsdaten geholt. Das hat eine Folge, die + # man kennen muss: der Checkout-Token faellt damit ebenfalls weg, der + # Checkout nutzt dann den Standard-Token des Jobs — und der kann die + # privaten Submodul-Repositories nicht klonen. Projekte MIT privaten + # Submodulen koennen den Modus push: false daher nicht nutzen; ihr Klon + # scheitert an "Repository not found". Fuer Projekte ohne Submodule ist + # der Modus unproblematisch. + # + # Zur Bedingung: ein boolescher workflow_call-Input kann je nach + # Schreibweise des Aufrufers als echter Boolean oder als String ankommen. + # `inputs.push` allein waere bei dem String 'false' wahr (nichtleere + # Strings sind truthy), `inputs.push == 'true'` waere beim echten Boolean + # true falsch (ein Vergleich ueber Typgrenzen laeuft ueber Zahlen, 'true' + # wird dabei zu NaN). Die Kombination aus beidem deckt alle vier Faelle ab. + - name: Zugangsdaten aus 1Password laden + id: op-secrets + if: ${{ inputs.push && inputs.push != 'false' }} + uses: 1password/load-secrets-action@v2 + with: + # Die Werte sollen nur als Step-Outputs entstehen und nicht in die + # Umgebung aller folgenden Schritte wandern. + export-env: false + env: + OP_CONNECT_HOST: ${{ vars.OP_CONNECT_HOST }} + OP_CONNECT_TOKEN: ${{ secrets.OP_CONNECT_TOKEN }} + # Klont die privaten Submodul-Repositories. + CHECKOUT_TOKEN: op://GitHub-Actions/jlab-git-checkout-token/credential + # Eigener Zugang fuer die Registry — der Checkout-Token darf dort + # nicht schreiben. Benutzername und Token kommen aus demselben Item, + # damit sie zwangslaeufig zusammenpassen; die Registry prueft den + # Token gegen genau das Konto aus `username`. + REGISTRY_USER: op://GitHub-Actions/jlab-registry-access/username + REGISTRY_TOKEN: op://GitHub-Actions/jlab-registry-access/password + + - uses: actions/checkout@v4 + with: + submodules: true + # Die Action legt je Referenz einen Output an, benannt wie die + # Umgebungsvariable, in der die op://-Referenz stand. + # + # Ist der 1Password-Schritt uebersprungen (push: false), sind seine + # Outputs leer. Ein leer uebergebenes token wuerde die Vorgabe der + # Action (github.token) ueberschreiben und den Checkout ohne jeden + # Zugang laufen lassen — er scheiterte schon am eigenen Repository. + # Der Fallback macht daraus den regulaeren Job-Token. Der genuegt + # fuer das ausfuehrende Repository, nicht fuer private Submodule + # (siehe Kommentar am 1Password-Schritt). + token: ${{ steps.op-secrets.outputs.CHECKOUT_TOKEN || github.token }} + # Schlaegt der Buildx-Start fehl, ist die erste Frage immer, welcher + # Socket im Job-Container ueberhaupt ankommt. Der Schritt beantwortet sie, + # ohne den Job scheitern zu lassen. + - name: Docker-Socket ermitteln + run: | + echo "DOCKER_HOST=${DOCKER_HOST:-}" + ls -l /var/run/docker.sock /run/docker.sock 2>&1 || true + ls -l /run/user/*/docker.sock 2>&1 || true + docker version 2>&1 || true + + - name: Set up Docker Buildx + id: buildx + uses: docker/setup-buildx-action@master + # Ohne Push gibt es nichts anzumelden — und die Zugangsdaten waeren + # ohnehin leer, weil der 1Password-Schritt uebersprungen wurde. Ein + # Login-Versuch mit leerem Benutzer scheiterte mit einer nichtssagenden + # Meldung; dieselbe Bedingung wie oben verhindert das. + - name: Authenticate To Registry + if: ${{ inputs.push && inputs.push != 'false' }} + uses: docker/login-action@v3 + with: + registry: ${{ inputs.registry }} + username: ${{ steps.op-secrets.outputs.REGISTRY_USER }} + password: ${{ steps.op-secrets.outputs.REGISTRY_TOKEN }} + - name: Build + uses: docker/build-push-action@v5 + with: + builder: ${{ steps.buildx.outputs.name }} + context: ${{ inputs.docker-context }} + file: ${{ inputs.dockerfile }} + platforms: ${{ inputs.platforms }} + build-args: ${{ inputs.build-args }} + # Dieselbe Normalisierung wie bei den if:-Bedingungen. Das Ergebnis + # ist immer genau "true" oder "false" — die Action liest den Wert als + # Boolean und akzeptiert nichts dazwischen. + push: ${{ inputs.push && inputs.push != 'false' }} + # Der Eigentümer stand frueher als IMAGE_OWNER im env-Block des + # Workflows. Dort kann er nicht bleiben: env auf Workflow-Ebene wird + # ausgewertet, bevor es einen inputs-Kontext gibt. Der Wert wird + # deshalb hier unmittelbar aus inputs gelesen — an dieser Stelle ist + # der Kontext sicher vorhanden, anders als in jedem env-Block. + # + # Ohne Push bleibt die Liste rein lokal: die Action gibt sie als --tag + # an den Build weiter, veroeffentlicht aber nichts. Die Versionen + # tragen dann ihre Vorgabe 0.0.0. + tags: | + ${{ inputs.registry }}/${{ inputs.image-owner }}/${{ inputs.container-name }}:latest + ${{ inputs.registry }}/${{ inputs.image-owner }}/${{ inputs.container-name }}:${{ inputs.version-number }} + ${{ inputs.registry }}/${{ inputs.image-owner }}/${{ inputs.container-name }}:${{ inputs.version-number-major }} + ${{ inputs.registry }}/${{ inputs.image-owner }}/${{ inputs.container-name }}:${{ inputs.version-number-major }}.${{ inputs.version-number-minor }} + ${{ inputs.registry }}/${{ inputs.image-owner }}/${{ inputs.container-name }}:${{ inputs.version-number-major }}.${{ inputs.version-number-minor }}.${{ inputs.version-number-patch }} diff --git a/README.md b/README.md index 24415e9..b682112 100644 --- a/README.md +++ b/README.md @@ -1 +1,142 @@ -# Hier stehen wichtige Infos! \ No newline at end of file +# 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`). + +## 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: + +``` +//:latest +//: +//: +//:. +//:.. +``` + +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 `//` | +| `push` | nein | `true` | 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. + +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 +`…/:`. + +#### Aufrufbeispiel + +```yaml +jobs: + image: + uses: workflow-util/pipelines/.gitea/workflows/docker-build-push.yml@v1 + 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 + 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@v1 + with: + container-name: bk-dev-website + dockerfile: ./Dockerfile + docker-context: . + image-owner: bekemeyer-klein + push: false +``` + +> **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. + +## 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@v1 # 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.