diff --git a/.gitea/workflows/publish-helm-chart.yml b/.gitea/workflows/publish-helm-chart.yml new file mode 100644 index 0000000..a3f9186 --- /dev/null +++ b/.gitea/workflows/publish-helm-chart.yml @@ -0,0 +1,249 @@ +name: Publish Helm Chart with patched Application Version + +# Called through "uses:" only, never triggered here. The thin wrappers in the +# calling repository carry their own "workflow_dispatch" and "on: push", so the +# workflow_dispatch block this file used to have is gone: a dispatch in THIS +# repository would run against this repository's files, where there is no chart. +# +# The comments below are kept from the copy this file replaces, in English as +# they were written. Each of them records something that was paid for once - +# none of it is restating what the YAML already says. +on: + workflow_call: + inputs: + chart-location: + required: false + type: string + default: "." + description: "location of the helm chart directory" + chart-name: + required: true + type: string + description: "name of the helm chart" + # Deliberately required and without a default, following the house rule + # of this repository: an owner segment is never guessed. The caller + # passes "${{ github.repository_owner }}", where the value is measured + # (jlab-cluster, run 282 of validate-charts.yml) - inside a workflow + # called from another repository it is documented to stay the caller's + # owner, but that is not measured on this Gitea version, and a published + # chart landing under the wrong owner is not worth relying on a context + # semantic for. As an input the value is evaluated in the caller, where + # it is known to be right. + # + # type: string, like every input here. On this Gitea version (measured on + # 1.27.0, issue #4) a boolean workflow_call input does not reach the + # called workflow at all - neither its default nor an explicitly passed + # value - while the run stays green. Every input of this block is + # therefore a string, even one that is a switch in meaning. + registry-owner: + required: true + type: string + description: >- + Owner segment of the chart registry URL, i.e. the in + //helm/api/charts. Deliberately has no default: an owner + segment is never guessed. Callers pass github.repository_owner, which + resolves in the calling repository, where the value is measured. + outputs: + chart-version: + description: "the version of the published helm chart" + value: ${{ jobs.publish-helm-chart.outputs.chart-version }} + secrets: + OP_CONNECT_TOKEN: + required: true +jobs: + publish-helm-chart: + outputs: + chart-version: ${{ steps.get-chart-version.outputs.CHART_VERSION }} + runs-on: ubuntu-latest + steps: + - name: "Checkout Repository" + uses: actions/checkout@v7 + - name: "Install Helm" + uses: azure/setup-helm@v5 + # The Connect host is a variable of the CALLING repository, not a value + # wired into this file: a shared building block must not carry one + # tenant's infrastructure address. The price of that is a prerequisite + # that can be forgotten, and an unset variable arrives as an empty + # string. An empty connect-host does not fail here - it fails two steps + # later, inside the loading action, against a URL nobody can see. So the + # guard comes first and names the setting, the place to set it and the + # shape of the value. + - name: "Check the calling repository's prerequisites" + env: + OP_CONNECT_HOST: ${{ vars.OP_CONNECT_HOST }} + run: |- + if [ -z "$OP_CONNECT_HOST" ]; then + echo "::error::the variable OP_CONNECT_HOST is not set in the calling repository (Settings -> Actions -> Variables). It must hold the address of the 1Password Connect server, e.g. https://op-connect.example.com. This workflow deliberately does not hardcode a host." + exit 1 + fi + - name: "Configure OP-Connect" + uses: 1password/load-secrets-action/configure@v5 + with: + connect-host: ${{ vars.OP_CONNECT_HOST }} + # helm package exits 1 with "found in Chart.yaml, but missing in charts/ + # directory" while a declared dependency has not been fetched, so this + # comes first. It is a no-op for every chart without a dependencies: + # block; ci/helm-dependencies.sh explains why it resolves from Chart.yaml + # instead of a committed Chart.lock. Note the consequence for the + # published artifact: the subchart .tgz is packaged INTO ours, so the + # version pinned in Chart.yaml is the version operators get, and nothing + # downstream ever sees the dependency again. + # + # The script belongs to the CALLING repository and that is unchanged by + # the move: the checkout above clones the repository the run belongs to - + # the caller - so ${{ github.workspace }} is the caller's tree and the + # script is the caller's copy. It is therefore a prerequisite of every + # caller, the same way OP_CONNECT_HOST is, and the guard below says so + # rather than letting the shell report a missing file. + - name: "Fetch chart dependencies" + env: + HELM_DEPENDENCIES_SCRIPT: ${{ github.workspace }}/ci/helm-dependencies.sh + CHART_NAME: ${{ inputs.chart-name }} + run: |- + test -x "$HELM_DEPENDENCIES_SCRIPT" || { + echo "::error::${HELM_DEPENDENCIES_SCRIPT} is missing or not executable - ci/helm-dependencies.sh is a script of the calling repository and this workflow runs it before helm package" + exit 1 + } + "$HELM_DEPENDENCIES_SCRIPT" "$CHART_NAME" + working-directory: ${{ inputs.chart-location }} + - name: "Package Helm Chart" + run: helm package ${{ inputs.chart-name }} + working-directory: ${{ inputs.chart-location }} + - name: "Get Helm Chart Version" + id: get-chart-version + run: |- + CHART_VERSION=$(grep '^version:' ${{ inputs.chart-name}}/Chart.yaml | awk '{print $2}') + echo $CHART_VERSION + echo "CHART_VERSION=$CHART_VERSION" >> $GITHUB_OUTPUT + working-directory: ${{ inputs.chart-location }} + - name: "Load Chart Registry Credentials" + id: load-registry-creds + uses: 1password/load-secrets-action@v5 + env: + OP_CONNECT_TOKEN: ${{ secrets.OP_CONNECT_TOKEN }} + REGISTRY_PASSWORD: op://GitHub-Actions/jlab-registry-access/password + REGISTRY_USERNAME: op://GitHub-Actions/jlab-registry-access/username + REGISTRY_CHARTS_URL: op://GitHub-Actions/jlab-registry-access/charts-url + # The target is a classic, index-based Helm registry, not an OCI one, so + # there is no "helm registry login" session and no "helm push": the upload + # is a single HTTP POST of the packaged .tgz to + # //helm/api/charts, authenticated per request. + # + # That URL is composed, not stored (jlab-cluster/helm-charts#63). + # charts-url holds only the base up to "/api/packages"; the owner segment + # comes from the registry-owner input, which the caller fills with + # github.repository_owner - measured as "jlab-cluster" in run 282 of + # validate-charts.yml, on a temporary step that was removed again. So no + # value pointing at the registry - not even a path segment - lives in the + # calling repository, and a second owner on the same instance needs no + # second setting. Two guards make the switch from the old shape loud + # instead of puzzling: charts-url is rejected unless it ends in + # "/api/packages" (an old full URL still carrying "/helm", "/api/charts" + # or the owner is named explicitly), and the owner is rejected when empty + # or when it holds characters that are not a path segment - an empty one + # would build "//helm/api/charts", i.e. another bare 404 against a + # masked URL. + # + # Three further properties of this step are load-bearing and each of them + # was paid for once already. + # + # 1) The values are mapped into the step's own env block and read as quoted + # shell variables - never interpolated into the command line. load-secrets- + # action v5 no longer exports the loaded values as environment variables + # for later steps the way v4 did; only the step outputs carry them. Every + # "${{ env.* }}" reference therefore resolved to an empty string from that + # bump on, with nothing reporting an error - the loading step logs + # "Populating variable" and stays green either way. The ":?" guards fail + # loudly and name the op:// field that is missing, and quoting keeps an + # empty value from silently shifting arguments. + # + # 2) The credentials never reach the process list. "curl --user $U:$P" + # would put them in argv; "curl --config -" reads its options from stdin + # instead, so nothing sensitive appears in any argument. The price of that + # config file is its value syntax: an unquoted value ends at the first + # space (measured on curl 8.5.0 - "has space" arrived as "has"), so the + # step refuses whitespace in the credentials up front rather than letting + # a truncated password come back as a puzzling 401. A Gitea access token + # is 40 hex characters, so this only ever fires on a mis-entered item. + # + # 3) "--fail-with-body" is what makes a failed upload fail the job. Unlike + # "helm push", curl exits 0 on HTTP 401/403/409 unless asked not to - the + # publish would go green while nothing was uploaded. Measured against Gitea + # 1.24.7: a 401 without the flag is exit 0, with the flag exit 22, and the + # response body is still printed so the log names the cause. + # + # The file name is built from the chart name and the version read above + # instead of a "-*.tgz" glob: --upload-file takes exactly one file, + # and a glob matching two turns the second into curl's URL argument, while + # a glob matching none is passed through literally. Both fail obscurely. + # "helm package" keeps the "+" of a "1.4.0+up1.9.2" version verbatim in the + # file name, so "-.tgz" is an exact match (measured). + - name: "Upload Helm Chart to the Chart Registry" + env: + REGISTRY_PASSWORD: ${{ steps.load-registry-creds.outputs.REGISTRY_PASSWORD }} + REGISTRY_USERNAME: ${{ steps.load-registry-creds.outputs.REGISTRY_USERNAME }} + REGISTRY_CHARTS_URL: ${{ steps.load-registry-creds.outputs.REGISTRY_CHARTS_URL }} + REGISTRY_OWNER: ${{ inputs.registry-owner }} + CHART_NAME: ${{ inputs.chart-name }} + CHART_VERSION: ${{ steps.get-chart-version.outputs.CHART_VERSION }} + run: |- + : "${REGISTRY_CHARTS_URL:?empty - check op://GitHub-Actions/jlab-registry-access/charts-url}" + : "${REGISTRY_USERNAME:?empty - check op://GitHub-Actions/jlab-registry-access/username}" + : "${REGISTRY_PASSWORD:?empty - check op://GitHub-Actions/jlab-registry-access/password}" + : "${REGISTRY_OWNER:?empty - the registry-owner input did not arrive; the caller passes it from github.repository_owner, do not hardcode an owner}" + : "${CHART_NAME:?empty - the chart-name input did not arrive}" + : "${CHART_VERSION:?empty - could not read version: from ${CHART_NAME}/Chart.yaml}" + # 1Password masks the full URL, but a curl connection error prints only + # ":", which that mask would not cover. Mask the host too. + CHARTS_HOST="${REGISTRY_CHARTS_URL#*://}" + echo "::add-mask::${CHARTS_HOST%%/*}" + # charts-url is the base only; this workflow appends the owner, the + # package type and the endpoint. Reject the old, full shape by name so + # the failure says what to change in the vault item instead of 404ing + # against a URL the log has masked. + CHARTS_BASE="${REGISTRY_CHARTS_URL%/}" + case "$CHARTS_BASE" in + */api/charts) + echo "::error::op://GitHub-Actions/jlab-registry-access/charts-url still ends in /api/charts - it must hold only the base up to /api/packages (https:///api/packages); this workflow appends //helm/api/charts itself" + exit 1 + ;; + */helm|*/helm/*) + echo "::error::op://GitHub-Actions/jlab-registry-access/charts-url still contains the /helm path segment - it must hold only the base up to /api/packages (https:///api/packages); this workflow appends //helm/api/charts itself" + exit 1 + ;; + */api/packages) ;; + *) + echo "::error::op://GitHub-Actions/jlab-registry-access/charts-url must end in /api/packages (https:///api/packages) - no owner, no package type, no endpoint; this workflow appends //helm/api/charts itself" + exit 1 + ;; + esac + # An empty owner would build "//helm/api/charts", a stray "/" or + # "?" would build something else entirely - both are bare 404s. + case "$REGISTRY_OWNER" in + *[!A-Za-z0-9._-]*) + echo "::error::the registry-owner input is not usable as a URL path segment: '${REGISTRY_OWNER}'" + exit 1 + ;; + esac + CHARTS_API_URL="${CHARTS_BASE}/${REGISTRY_OWNER}/helm/api/charts" + case "${REGISTRY_USERNAME}${REGISTRY_PASSWORD}" in + *[[:space:]]*) + echo "::error::username/password in op://GitHub-Actions/jlab-registry-access must not contain whitespace - curl --config truncates an unquoted value at the first space" + exit 1 + ;; + esac + CHART_PACKAGE="${CHART_NAME}-${CHART_VERSION}.tgz" + test -f "$CHART_PACKAGE" || { + echo "::error::${CHART_PACKAGE} does not exist - helm package produced: $(ls -1 ./*.tgz 2>&1)" + exit 1 + } + echo "uploading ${CHART_PACKAGE}" + printf 'user = %s:%s\nurl = %s\n' \ + "$REGISTRY_USERNAME" "$REGISTRY_PASSWORD" "$CHARTS_API_URL" | + curl --config - \ + --fail-with-body \ + --silent --show-error \ + --request POST \ + --upload-file "$CHART_PACKAGE" \ + --write-out '\nHTTP %{http_code}\n' + working-directory: ${{ inputs.chart-location }} diff --git a/README.md b/README.md index 7b1568c..a9b472d 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,12 @@ 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` @@ -148,6 +154,121 @@ 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: + +``` +//helm/api/charts +``` + +`` ist die Basis bis `/api/packages` und liegt in 1Password +(`GitHub-Actions/jlab-registry-access`, Feld `charts-url`), `` +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 `` in `//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:///api/packages # so +https:///api/packages//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 +`//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. + ## Auf einen Tag zeigen, nicht auf `@main` Aufrufe bitte immer an einen Tag binden: @@ -169,3 +290,19 @@ bestehende Aufrufe, wird der Major-Anteil erhöht. 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; unter `v1` und `v2` existiert die Datei nicht, +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 `v4` — 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` selbst ist additiv. Der neue Baustein ändert an `docker-build-push.yml` +nichts, bestehende Aufrufer auf `@v2` bleiben unberührt.