name: Publish Helm Chart with patched Application Version on: workflow_dispatch: 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" 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" 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 - name: "Configure OP-Connect" uses: 1password/load-secrets-action/configure@v5 with: connect-host: https://op-connect.jk-dev.de # 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. - name: "Fetch chart dependencies" run: ${{ github.workspace }}/ci/helm-dependencies.sh "${{ inputs.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 (#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- # action v5 (bumped in #140) 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: ${{ 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" 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 }}