From a12983fbea30fdbefe199f41954dc9074740533c Mon Sep 17 00:00:00 2001 From: Justin Klein Date: Wed, 30 Sep 2026 18:49:56 +0000 Subject: [PATCH] ci(publish): compose the registry URL from a base and the repository owner charts-url in op://GitHub-Actions/jlab-registry-access held the full registry URL and publish-helm-chart.yml appended /api/charts to it. It now holds only the base up to /api/packages, and the owner segment comes from github.repository_owner, so the target is composed as //helm/api/charts. Nothing in this repository points at the registry any more, not even a path segment, and the second owner on the same instance needs no second setting. The 26 thin publish-.yml callers are unchanged. Two guards keep the switch from the old shape loud rather than a 404 against a masked URL: charts-url is rejected unless it ends in /api/packages, naming /helm, /api/charts or an included owner explicitly; and the derived owner is rejected when empty or when it is not a usable URL path segment. Everything the upload step already had is untouched - the per-field :? guards, the whitespace check on the credentials, the host masking, the built file name, --fail-with-body. The probe step in validate-charts.yml is temporary and measures whether this forge populates github.repository_owner at all; it is removed again once the value has been read off the run. --- .github/workflows/publish-helm-chart.yml | 56 ++++++++++++++++++++++-- .github/workflows/validate-charts.yml | 10 +++++ README.md | 2 +- 3 files changed, 63 insertions(+), 5 deletions(-) diff --git a/.github/workflows/publish-helm-chart.yml b/.github/workflows/publish-helm-chart.yml index 62337f9..5264ef8 100644 --- a/.github/workflows/publish-helm-chart.yml +++ b/.github/workflows/publish-helm-chart.yml @@ -74,9 +74,26 @@ jobs: 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 /api/charts, - # authenticated per request. Three properties of this step are load-bearing - # and each of them was paid for once already. + # is a single HTTP POST of the packaged .tgz to + # //helm/api/charts, authenticated per request. + # + # That URL is composed, not stored (#63). charts-url holds only the base + # up to "/api/packages"; the owner segment comes from + # github.repository_owner, so no value pointing at the registry - not even + # a path segment - lives in this repository, and a second owner on the same + # instance needs no second setting. The mirror on github.com has Actions + # switched off, so the publish never runs there; if it ever did, the + # derived owner would be one the instance does not know and the upload + # would 404 rather than land somewhere wrong. 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 derived + # 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- @@ -114,18 +131,49 @@ jobs: 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: ${{ github.repository_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 - github.repository_owner is not populated on this forge; do not hardcode an owner, report it}" : "${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 derived repository owner 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" @@ -139,7 +187,7 @@ jobs: } echo "uploading ${CHART_PACKAGE}" printf 'user = %s:%s\nurl = %s\n' \ - "$REGISTRY_USERNAME" "$REGISTRY_PASSWORD" "${REGISTRY_CHARTS_URL%/}/api/charts" | + "$REGISTRY_USERNAME" "$REGISTRY_PASSWORD" "$CHARTS_API_URL" | curl --config - \ --fail-with-body \ --silent --show-error \ diff --git a/.github/workflows/validate-charts.yml b/.github/workflows/validate-charts.yml index 75589a7..1fcbb94 100644 --- a/.github/workflows/validate-charts.yml +++ b/.github/workflows/validate-charts.yml @@ -14,6 +14,16 @@ jobs: install-charts: ${{ steps.detect.outputs.install-charts }} any-install: ${{ steps.detect.outputs.any-install }} steps: + # TEMPORARY (#63) - remove before the PR is merged. Measures whether this + # forge populates github.repository_owner at all; the publish URL is about + # to be composed from it. + - name: "TEMPORARY (#63): probe github.repository_owner" + env: + PROBE_OWNER: ${{ github.repository_owner }} + run: | + echo "github.repository_owner = [${PROBE_OWNER}]" + echo "github.repository = [${{ github.repository }}]" + echo "length = ${#PROBE_OWNER}" - name: "Checkout Repository" uses: actions/checkout@v7 with: diff --git a/README.md b/README.md index 62ed9d3..a56cace 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ Every chart has a wiki page of its own — `https://git.jk-dev.de/jlab-cluster/h ## CI/CD - `publish-.yml` — one thin workflow per chart; triggers on pushes to `main` touching `/**` (or manually) and calls the reusable workflow below. **A push to a chart directory on `main` publishes that chart**, so the chart version must be bumped in the same change. -- `publish-helm-chart.yml` — reusable workflow: `helm package`, then a single HTTP `POST` of the packaged `.tgz` to the private chart registry. The target is a **classic, index-based** Helm registry (Gitea's `/api/charts` endpoint), not an OCI one: `helm push` speaks only OCI and is not used, there is no `helm registry login` session, and every request carries its own Basic auth. Credentials **and the target URL** come from 1Password Connect (`OP_CONNECT_TOKEN` secret, item `op://GitHub-Actions/jlab-registry-access/` with `username`, `password`, `charts-url`); the workflow appends `/api/charts` to `charts-url`. No registry address ever appears in this repository. The loaded values are read via **`steps..outputs.*`**, mapped into the step's own `env:` block and used as quoted shell variables — never interpolated into the command line. `load-secrets-action` **v5 no longer exports them as environment variables** for later steps the way v4 did, so after the v5 bump every `${{ env.* }}` reference resolved to an empty string while the loading step still logged `Populating variable` and stayed green. Each value is additionally guarded with `:?`, because an empty one used to collapse into a missing argument rather than an error. Three properties of the upload step are load-bearing: +- `publish-helm-chart.yml` — reusable workflow: `helm package`, then a single HTTP `POST` of the packaged `.tgz` to the private chart registry. The target is a **classic, index-based** Helm registry (Gitea's `/api/charts` endpoint), not an OCI one: `helm push` speaks only OCI and is not used, there is no `helm registry login` session, and every request carries its own Basic auth. Credentials **and the target URL** come from 1Password Connect (`OP_CONNECT_TOKEN` secret, item `op://GitHub-Actions/jlab-registry-access/` with `username`, `password`, `charts-url`). `charts-url` holds only the **base up to `/api/packages`**; the workflow composes the target as `//helm/api/charts`, and the owner comes from **`github.repository_owner`** (#63) — so the 26 thin callers stay as they are and a second owner on the same instance needs no second setting. **No registry address ever appears in this repository**, and since #63 not even the owner path segment: it is derived from the repository rather than stored. Both halves of that composition are guarded, because the only other signal would be a 404 against a URL the log has masked: `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 in the error), and the derived owner is rejected when empty or when it is not a usable path segment — an empty one would build `//helm/api/charts`. The loaded values are read via **`steps..outputs.*`**, mapped into the step's own `env:` block and used as quoted shell variables — never interpolated into the command line. `load-secrets-action` **v5 no longer exports them as environment variables** for later steps the way v4 did, so after the v5 bump every `${{ env.* }}` reference resolved to an empty string while the loading step still logged `Populating variable` and stayed green. Each value is additionally guarded with `:?`, because an empty one used to collapse into a missing argument rather than an error. Three properties of the upload step are load-bearing: - **Credentials stay out of the process list.** `curl --user "$U:$P"` would put them in `argv`; the step uses `curl --config -` and pipes the options in on stdin instead. - **A failed upload fails the job.** Unlike `helm push`, `curl` exits **0** on HTTP 401/403/409 — a publish would go green with nothing uploaded. `--fail-with-body` turns those into a non-zero exit and still prints the response body, so the log names the cause. - **The file name is built, not globbed.** `-.tgz` is assembled from the chart name and the version already read from `Chart.yaml`; `--upload-file` takes exactly one file, and a glob matching two silently turns the second into curl's URL argument. `helm package` keeps the `+` of a `1.4.0+up1.9.2` version verbatim in the file name, so the built name is an exact match.