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 }}