From ac923a1c0699b0b9310c9c77c483da2bab33e8a6 Mon Sep 17 00:00:00 2001 From: Dmytro Sydorov Date: Wed, 15 Apr 2026 16:06:11 +0200 Subject: [PATCH] refactor(publish-helm-chart): convert reusable workflow to composite action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reusable workflow couldn't resolve its own ref at call time. `github.workflow_ref` and `github.workflow_sha` both leak the caller's context under `workflow_call` — the sparse-checkout of `loft-sh/github-actions` for the scripts used the parsed workflow_ref (`refs/heads/main` for push events, `refs/tags/vX` for releases), which: - On push-to-main: happened to resolve because main/main lined up, but silently pulled scripts from `main` rather than the pinned `@publish-helm-chart/v1` — tag pinning was a lie. - On release events: the caller's `refs/tags/v4.6.0` doesn't exist in `loft-sh/github-actions`, so the first release-triggered caller (pending loft-enterprise release-chart migration) would fail with `fatal: couldn't find remote ref`. Converting to a composite action sidesteps both — `github.action_path` is correct automatically, tag pinning is real, no release-event breakage. Same pattern already used by ci-test-notify, release-notification, linear-release-sync, semver-validation, govulncheck (#109), and go-licenses (#100). Caller contract change (v2, breaking): - `ref` input removed — caller checks out the desired ref themselves - `runs-on` / `timeout-minutes` / `permissions` set by caller - `secrets: chart-museum-*` → regular inputs (actions can't declare secrets; caller interpolates `${{ secrets.* }}`) - Caller supplies `actions/checkout` before the action Only caller today is loft-enterprise push-head-images.yaml (merged via #6673 yesterday) — one migration PR follows. - `.github/scripts/publish-helm-chart/` → `.github/actions/publish-helm-chart/` - Delete `.github/workflows/publish-helm-chart.yaml` - Retarget `test-publish-helm-chart.yaml` path filter + test target - Rewrite README section for the composite-action contract Refs DEVOPS-772 --- .github/actions/publish-helm-chart/README.md | 92 +++++++++++++ .github/actions/publish-helm-chart/action.yml | 84 ++++++++++++ .../publish-helm-chart/run.sh | 0 .../publish-helm-chart/test/run.bats | 0 .github/workflows/publish-helm-chart.yaml | 125 ------------------ .../workflows/test-publish-helm-chart.yaml | 6 +- Makefile | 2 +- README.md | 57 ++++---- 8 files changed, 214 insertions(+), 152 deletions(-) create mode 100644 .github/actions/publish-helm-chart/README.md create mode 100644 .github/actions/publish-helm-chart/action.yml rename .github/{scripts => actions}/publish-helm-chart/run.sh (100%) rename .github/{scripts => actions}/publish-helm-chart/test/run.bats (100%) delete mode 100644 .github/workflows/publish-helm-chart.yaml diff --git a/.github/actions/publish-helm-chart/README.md b/.github/actions/publish-helm-chart/README.md new file mode 100644 index 0000000..d30cb95 --- /dev/null +++ b/.github/actions/publish-helm-chart/README.md @@ -0,0 +1,92 @@ +# publish-helm-chart + +Packages a Helm chart and pushes it to ChartMuseum. Handles multi-version publishes, in-place `Chart.yaml` / `values.yaml` edits via yq, and optional latest-semver re-push for stable release streams. + +## Inputs + +| Name | Description | Required | Default | +|------|-------------|----------|---------| +| `chart-name` | Written to `.name` in `Chart.yaml`; also determines the packaged tarball filename | yes | — | +| `chart-description` | Optional value written to `.description` in `Chart.yaml`. Preserved when empty | no | `""` | +| `app-version` | Passed as `--app-version` to `helm package`. When empty, the chart's existing `appVersion` is used | no | `""` | +| `chart-versions` | JSON array of chart versions. Each entry is packaged and pushed as `-.tgz`. Examples: `'["1.2.3"]'`, `'["0.0.0-latest","0.0.0-abc1234"]'` | yes | — | +| `chart-directory` | Path to the Helm chart source directory | no | `chart` | +| `values-edits` | Newline-separated `jsonpath=value` pairs applied via yq to `/values.yaml`. Values are written as strings | no | `""` | +| `helm-version` | Helm CLI version to install | no | `v4.1.4` | +| `republish-latest` | When `"true"`, after pushing, re-push the highest semver so it becomes the most recently uploaded entry (for stable release streams) | no | `"false"` | +| `chart-museum-url` | ChartMuseum base URL | no | `https://charts.loft.sh/` | +| `chart-museum-user` | ChartMuseum username | yes | — | +| `chart-museum-password` | ChartMuseum password | yes | — | + +## Usage + +### Head chart on push-to-main + +```yaml +name: Push head images + +on: + push: + branches: [main] + +jobs: + publish-head-chart: + runs-on: ubuntu-24.04 + permissions: + contents: read + timeout-minutes: 15 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: loft-sh/github-actions/.github/actions/publish-helm-chart@publish-helm-chart/v2 + with: + chart-name: vcluster-head + app-version: head-${{ github.sha }} + chart-versions: '["0.0.0-latest","0.0.0-${{ github.sha }}"]' + chart-museum-user: ${{ secrets.CHART_MUSEUM_USER }} + chart-museum-password: ${{ secrets.CHART_MUSEUM_PASSWORD }} +``` + +### Release chart (custom ref + dual product) + +```yaml +jobs: + publish-release-chart: + runs-on: ubuntu-24.04 + strategy: + matrix: + product: + - {name: loft, description: "Loft chart"} + - {name: vcluster-platform, description: "vCluster Platform chart"} + permissions: + contents: read + timeout-minutes: 15 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event.release.tag_name }} + persist-credentials: false + - uses: loft-sh/github-actions/.github/actions/publish-helm-chart@publish-helm-chart/v2 + with: + chart-name: ${{ matrix.product.name }} + chart-description: ${{ matrix.product.description }} + chart-versions: '["${{ inputs.release_version }}"]' + values-edits: | + .product=${{ matrix.product.name }} + republish-latest: "true" + chart-museum-user: ${{ secrets.CHART_MUSEUM_USER }} + chart-museum-password: ${{ secrets.CHART_MUSEUM_PASSWORD }} +``` + +## Notes + +The caller owns `actions/checkout` — so to publish a specific ref (e.g. a release tag), pass it to `actions/checkout` directly rather than through this action. + +## Testing + +```bash +make test-publish-helm-chart +``` + +Runs the bats suite in `test/run.bats` against `run.sh` with stubbed `helm` and `yq` binaries. Requires `mikefarah/yq` on `PATH` for the real-yq assertions. diff --git a/.github/actions/publish-helm-chart/action.yml b/.github/actions/publish-helm-chart/action.yml new file mode 100644 index 0000000..28e3dd4 --- /dev/null +++ b/.github/actions/publish-helm-chart/action.yml @@ -0,0 +1,84 @@ +name: 'Publish Helm chart' +description: "Package a Helm chart and push it to ChartMuseum. Supports multi-version publishes, values.yaml edits, and latest-semver re-push for stable release streams." +branding: + icon: "package" + color: "blue" + +inputs: + chart-name: + description: "Helm chart name. Written to .name in Chart.yaml; also determines the packaged tarball filename." + required: true + chart-description: + description: "Optional value written to .description in Chart.yaml. When empty, the existing description is preserved." + required: false + default: "" + app-version: + description: "Optional value passed as --app-version to `helm package`. When empty, the chart's existing appVersion is used." + required: false + default: "" + chart-versions: + description: "JSON array of chart versions to publish. Each entry is packaged and pushed as -.tgz. Examples: '[\"1.2.3\"]' or '[\"0.0.0-latest\",\"0.0.0-abc1234\"]'." + required: true + chart-directory: + description: "Path to the Helm chart source directory." + required: false + default: chart + values-edits: + description: "Optional newline-separated `jsonpath=value` pairs applied via yq to /values.yaml. Values are written as strings." + required: false + default: "" + helm-version: + description: "Helm CLI version to install." + required: false + # renovate: datasource=github-releases depName=helm/helm + default: v4.1.4 + republish-latest: + description: "When true, after pushing, query ChartMuseum for the highest semver of and re-push it so it becomes the most recently uploaded entry. Use for stable release publishing into a multi-line release stream." + required: false + default: "false" + chart-museum-url: + description: "ChartMuseum base URL." + required: false + default: https://charts.loft.sh/ + chart-museum-user: + description: "ChartMuseum username." + required: true + chart-museum-password: + description: "ChartMuseum password." + required: true + +runs: + using: "composite" + steps: + - name: Set up Helm + uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # v5.0.0 + with: + version: ${{ inputs.helm-version }} + + - name: Install helm-push plugin + # --verify=false: Helm v4 verifies plugin sources by default; the + # chartmuseum/helm-push plugin does not ship a verification manifest, + # so the install fails without this flag. The flag is a no-op on v3. + shell: bash + run: helm plugin install --verify=false https://github.com/chartmuseum/helm-push.git + + - name: Set up yq + uses: dcarbone/install-yq-action@4075b4dca348d74bd83f2bf82d30f25d7c54539b # v1.3.1 + with: + # renovate: datasource=github-releases depName=mikefarah/yq + version: v4.52.5 + + - name: Publish chart + shell: bash + env: + CHART_DIRECTORY: ${{ inputs.chart-directory }} + CHART_NAME: ${{ inputs.chart-name }} + CHART_DESCRIPTION: ${{ inputs.chart-description }} + APP_VERSION: ${{ inputs.app-version }} + CHART_VERSIONS_JSON: ${{ inputs.chart-versions }} + VALUES_EDITS: ${{ inputs.values-edits }} + REPUBLISH_LATEST: ${{ inputs.republish-latest }} + CHART_MUSEUM_URL: ${{ inputs.chart-museum-url }} + CHART_MUSEUM_USER: ${{ inputs.chart-museum-user }} + CHART_MUSEUM_PASSWORD: ${{ inputs.chart-museum-password }} + run: ${{ github.action_path }}/run.sh diff --git a/.github/scripts/publish-helm-chart/run.sh b/.github/actions/publish-helm-chart/run.sh similarity index 100% rename from .github/scripts/publish-helm-chart/run.sh rename to .github/actions/publish-helm-chart/run.sh diff --git a/.github/scripts/publish-helm-chart/test/run.bats b/.github/actions/publish-helm-chart/test/run.bats similarity index 100% rename from .github/scripts/publish-helm-chart/test/run.bats rename to .github/actions/publish-helm-chart/test/run.bats diff --git a/.github/workflows/publish-helm-chart.yaml b/.github/workflows/publish-helm-chart.yaml deleted file mode 100644 index f4bfe08..0000000 --- a/.github/workflows/publish-helm-chart.yaml +++ /dev/null @@ -1,125 +0,0 @@ -name: Publish Helm chart (reusable) - -on: - workflow_call: - inputs: - chart-name: - description: "Helm chart name. Written to .name in Chart.yaml; also determines the packaged tarball filename." - type: string - required: true - chart-description: - description: "Optional value written to .description in Chart.yaml. When empty, the existing description is preserved." - type: string - required: false - default: "" - app-version: - description: "Optional value passed as --app-version to `helm package`. When empty, the chart's existing appVersion is used." - type: string - required: false - default: "" - chart-versions: - description: "JSON array of chart versions to publish. Each entry is packaged and pushed as -.tgz. Examples: '[\"1.2.3\"]' or '[\"0.0.0-latest\",\"0.0.0-abc1234\"]'." - type: string - required: true - chart-directory: - description: "Path to the Helm chart source directory." - type: string - required: false - default: chart - values-edits: - description: "Optional newline-separated `jsonpath=value` pairs applied via yq to /values.yaml. Values are written as strings." - type: string - required: false - default: "" - helm-version: - description: "Helm CLI version to install." - type: string - required: false - # renovate: datasource=github-releases depName=helm/helm - default: v4.1.4 - ref: - description: "Optional git ref to checkout before publishing (e.g. a release tag). Defaults to the caller's GITHUB_REF." - type: string - required: false - default: "" - republish-latest: - description: "When true, after pushing, query ChartMuseum for the highest semver of and re-push it so it becomes the most recently uploaded entry. Use for stable release publishing into a multi-line release stream." - type: boolean - required: false - default: false - chart-museum-url: - description: "ChartMuseum base URL." - type: string - required: false - default: https://charts.loft.sh/ - secrets: - chart-museum-user: - description: "ChartMuseum username." - required: true - chart-museum-password: - description: "ChartMuseum password." - required: true - -jobs: - publish: - name: Publish ${{ inputs.chart-name }} - runs-on: ubuntu-24.04 - timeout-minutes: 15 - permissions: - contents: read - steps: - - name: Resolve reusable workflow ref - id: wref - env: - WORKFLOW_REF: ${{ github.workflow_ref }} - run: | - # `github.workflow_sha` resolves to the caller's commit in - # workflow_call, so we parse `owner/repo/path@ref` from - # workflow_ref to get the ref of the reusable workflow itself. - echo "ref=${WORKFLOW_REF##*@}" >> "${GITHUB_OUTPUT}" - - - name: Check out caller repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - ref: ${{ inputs.ref }} - persist-credentials: false - - - name: Check out reusable workflow scripts - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - repository: loft-sh/github-actions - ref: ${{ steps.wref.outputs.ref }} - persist-credentials: false - sparse-checkout: .github/scripts/publish-helm-chart - path: .github-actions-scripts - - - name: Set up Helm - uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # v5.0.0 - with: - version: ${{ inputs.helm-version }} - - - name: Install helm-push plugin - # --verify=false: Helm v4 verifies plugin sources by default; the - # chartmuseum/helm-push plugin does not ship a verification manifest, - # so the install fails without this flag. The flag is a no-op on v3. - run: helm plugin install --verify=false https://github.com/chartmuseum/helm-push.git - - - name: Set up yq - uses: dcarbone/install-yq-action@4075b4dca348d74bd83f2bf82d30f25d7c54539b # v1.3.1 - with: - # renovate: datasource=github-releases depName=mikefarah/yq - version: v4.52.5 - - - name: Publish chart - env: - CHART_DIRECTORY: ${{ inputs.chart-directory }} - CHART_NAME: ${{ inputs.chart-name }} - CHART_DESCRIPTION: ${{ inputs.chart-description }} - APP_VERSION: ${{ inputs.app-version }} - CHART_VERSIONS_JSON: ${{ inputs.chart-versions }} - VALUES_EDITS: ${{ inputs.values-edits }} - REPUBLISH_LATEST: ${{ inputs.republish-latest }} - CHART_MUSEUM_URL: ${{ inputs.chart-museum-url }} - CHART_MUSEUM_USER: ${{ secrets.chart-museum-user }} # zizmor: ignore[secrets-outside-env] -- passed via workflow_call, not a repo secret - CHART_MUSEUM_PASSWORD: ${{ secrets.chart-museum-password }} # zizmor: ignore[secrets-outside-env] -- passed via workflow_call, not a repo secret - run: .github-actions-scripts/.github/scripts/publish-helm-chart/run.sh diff --git a/.github/workflows/test-publish-helm-chart.yaml b/.github/workflows/test-publish-helm-chart.yaml index dc12bec..14640f1 100644 --- a/.github/workflows/test-publish-helm-chart.yaml +++ b/.github/workflows/test-publish-helm-chart.yaml @@ -3,8 +3,8 @@ name: Test publish-helm-chart on: pull_request: paths: - - '.github/scripts/publish-helm-chart/**' - - '.github/workflows/publish-helm-chart.yaml' + - '.github/actions/publish-helm-chart/**' + - '.github/workflows/test-publish-helm-chart.yaml' permissions: {} @@ -22,4 +22,4 @@ jobs: version: v4.52.5 - uses: bats-core/bats-action@77d6fb60505b4d0d1d73e48bd035b55074bbfb43 # 4.0.0 with: - tests: .github/scripts/publish-helm-chart/test + tests: .github/actions/publish-helm-chart/test diff --git a/Makefile b/Makefile index 7fc6d4a..fd50786 100644 --- a/Makefile +++ b/Makefile @@ -30,7 +30,7 @@ test-ci-test-notify: ## run ci-test-notify bats tests bats $(ACTIONS_DIR)/ci-test-notify/test/build-payload.bats test-publish-helm-chart: ## run publish-helm-chart bats tests (requires mikefarah/yq on PATH) - bats $(SCRIPTS_DIR)/publish-helm-chart/test/run.bats + bats $(ACTIONS_DIR)/publish-helm-chart/test/run.bats test-govulncheck: ## run govulncheck bats tests bats $(ACTIONS_DIR)/govulncheck/test/run.bats diff --git a/README.md b/README.md index 66e5cb7..e2e1c9d 100644 --- a/README.md +++ b/README.md @@ -155,24 +155,29 @@ pushes (multiple `0.0.0-*` versions) under the same contract. Optionally re-pushes the repo's highest semver afterwards so it stays first in the upload-ordered ChartMuseum index. -**Location:** `.github/workflows/publish-helm-chart.yaml` +**Location:** `.github/actions/publish-helm-chart` **Usage (release push):** ```yaml jobs: publish-chart: + runs-on: ubuntu-24.04 permissions: contents: read - uses: loft-sh/github-actions/.github/workflows/publish-helm-chart.yaml@publish-helm-chart/v1 - with: - chart-name: vcluster - app-version: 1.2.3 - chart-versions: '["1.2.3"]' - ref: v1.2.3 - secrets: - chart-museum-user: ${{ secrets.CHART_MUSEUM_USER }} - chart-museum-password: ${{ secrets.CHART_MUSEUM_PASSWORD }} + timeout-minutes: 15 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: v1.2.3 + persist-credentials: false + - uses: loft-sh/github-actions/.github/actions/publish-helm-chart@publish-helm-chart/v2 + with: + chart-name: vcluster + app-version: 1.2.3 + chart-versions: '["1.2.3"]' + chart-museum-user: ${{ secrets.CHART_MUSEUM_USER }} + chart-museum-password: ${{ secrets.CHART_MUSEUM_PASSWORD }} ``` **Usage (head/dev push):** @@ -180,17 +185,22 @@ jobs: ```yaml jobs: push-head-chart: + runs-on: ubuntu-24.04 permissions: contents: read - uses: loft-sh/github-actions/.github/workflows/publish-helm-chart.yaml@publish-helm-chart/v1 - with: - chart-name: vcluster-head - chart-description: "vCluster HEAD - Development builds from main branch" - app-version: head-${{ github.sha }} - chart-versions: '["0.0.0-latest","0.0.0-${{ github.sha }}"]' - secrets: - chart-museum-user: ${{ secrets.CHART_MUSEUM_USER }} - chart-museum-password: ${{ secrets.CHART_MUSEUM_PASSWORD }} + timeout-minutes: 15 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: loft-sh/github-actions/.github/actions/publish-helm-chart@publish-helm-chart/v2 + with: + chart-name: vcluster-head + chart-description: "vCluster HEAD - Development builds from main branch" + app-version: head-${{ github.sha }} + chart-versions: '["0.0.0-latest","0.0.0-${{ github.sha }}"]' + chart-museum-user: ${{ secrets.CHART_MUSEUM_USER }} + chart-museum-password: ${{ secrets.CHART_MUSEUM_PASSWORD }} ``` **Inputs:** @@ -201,12 +211,13 @@ jobs: - `chart-versions` (required): JSON array of versions, e.g. `'["1.2.3"]'` - `chart-directory` (optional, default: `chart`): chart source path - `values-edits` (optional): newline-separated `jsonpath=value` pairs applied via yq to `/values.yaml` -- `helm-version` (optional, default: `v3.20.0`) -- `ref` (optional): git ref to checkout (e.g. release tag) -- `republish-latest` (optional, default: `false`): re-push highest semver to keep it first in the ChartMuseum index +- `helm-version` (optional, default: `v4.1.4`) +- `republish-latest` (optional, default: `"false"`): re-push highest semver to keep it first in the ChartMuseum index - `chart-museum-url` (optional, default: `https://charts.loft.sh/`) +- `chart-museum-user` (required) +- `chart-museum-password` (required) -**Secrets:** `chart-museum-user`, `chart-museum-password`. +**Note:** The `ref` input was removed — the caller owns `actions/checkout` and checks out the desired ref directly. ### Govulncheck