diff --git a/README.md b/README.md index 8e377c6..e537076 100644 --- a/README.md +++ b/README.md @@ -681,6 +681,40 @@ githubWorkflow: `platforms` configures for which platforms the multi-arch docker image is built. Defaults to `linux/amd64`. Note: emulation is provided by qemu and might take significant time. `tagStrategy` influences which container tags will be pushed. Currently `edge`, `latest`, `semver` and `sha` are supported. +### `githubWorkflow.pushHelmChartToGhcr` + +If `path` is set to a valid Helm chart path inside the repository, the chart is automatically packaged and pushed as an OCI artifact to `oci://ghcr.io/${{ github.repository_owner }}/charts`, i.e. into the `charts` namespace owned by the GitHub user or organization that owns the repository. + +```yaml +githubWorkflow: + pushHelmChartToGhcr: + path: charts/my-chart +``` + +`lint` configures whether the Helm chart should be linted with `helm lint` before packaging and pushing. Defaults to `true`. + +`dependencyUpdate` configures whether chart dependencies should be updated with `helm dependency update` before packaging and pushing. Defaults to `true`. + +`disableVersioning` disables automatic version detection from the `pushContainerToGhcr` workflow. Has no effect when the `pushContainerToGhcr` workflow is disabled. Defaults to `false`. + +When the repository uses the `pushContainerToGhcr` workflow with `semver` or `sha` tag strategy, the Helm chart's single `version` is derived from that strategy: if a valid semver tag is present, it becomes the chart version; otherwise a SHA-based version is used. +Only one version is applied to the chart per workflow run; it does not replicate additional container tags such as semver major/minor aliases. The `semver` strategy takes precedence over `sha` when both are enabled and a valid semver tag exists. + +#### Using AppVersion inside the Chart to link Chart version and image + +The Chart's AppVersion is automatically set to either the release semver or a SHA-based image tag (for example, `sha-`) of the commit that triggered the workflow, overriding the version specified in `Chart.yaml`. This allows you to reference the image tag directly inside your chart using `{{ .Chart.AppVersion }}`, for example: + +```yaml +kind: Deployment +spec: +... + image: ghcr.io/my-org/my-repo:{{ .Chart.AppVersion }} +``` + +#### Disabling auto-versioning and using the version from `Chart.yaml` instead + +To disable auto-versioning and use the version specified in `Chart.yaml`, set `disableVersioning` to `true`. + ### `githubWorkflow.release` If `release` is enabled a workflow is generated which creates a new GitHub release using goreleaser when a git tag is pushed. diff --git a/internal/core/config.go b/internal/core/config.go index 19fd31d..895fbc4 100644 --- a/internal/core/config.go +++ b/internal/core/config.go @@ -178,6 +178,7 @@ type GithubWorkflowConfiguration struct { PushContainerToGhcr PushContainerToGhcrConfig `yaml:"pushContainerToGhcr"` Release ReleaseWorkflowConfig `yaml:"release"` SecurityChecks SecurityChecksWorkflowConfig `yaml:"securityChecks"` + PushHelmChartToGhcr PushHelmChartToGhcrConfig `yaml:"pushHelmChartToGhcr"` } // CIWorkflowConfig appears in type Configuration. @@ -206,6 +207,14 @@ type PushContainerToGhcrConfig struct { TagStrategy []string `yaml:"tagStrategy"` } +// PushHelmChartToGhcrConfig appears in type GithubWorkflowConfiguration. +type PushHelmChartToGhcrConfig struct { + Path Option[string] `yaml:"path"` + Lint Option[bool] `yaml:"lint"` + DependencyUpdate Option[bool] `yaml:"dependencyUpdate"` + DisableVersioning bool `yaml:"disableVersioning"` +} + // ReleaseWorkflowConfig appears in type ReleaseWorkflowConfig. type ReleaseWorkflowConfig struct { Enabled Option[bool] `yaml:"enabled"` diff --git a/internal/core/constants.go b/internal/core/constants.go index ac259f3..b306f37 100644 --- a/internal/core/constants.go +++ b/internal/core/constants.go @@ -75,4 +75,5 @@ const ( GoreleaserAction = "goreleaser/goreleaser-action@v7" ReuseAction = "fsfe/reuse-action@v6" TyposAction = "crate-ci/typos@v1" + HelmSetupAction = "azure/setup-helm@v4" ) diff --git a/internal/ghworkflow/render.go b/internal/ghworkflow/render.go index a798a60..82a5f65 100644 --- a/internal/ghworkflow/render.go +++ b/internal/ghworkflow/render.go @@ -37,6 +37,7 @@ func Render(cfg core.Configuration, sr golang.ScanResult) { ciWorkflow(cfg, sr) codeQLWorkflow(cfg) } + helmWorkflow(cfg) ghcrWorkflow(ghwCfg) releaseWorkflow(cfg) } diff --git a/internal/ghworkflow/workflow_helm.go b/internal/ghworkflow/workflow_helm.go new file mode 100644 index 0000000..af83f4e --- /dev/null +++ b/internal/ghworkflow/workflow_helm.go @@ -0,0 +1,120 @@ +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company +// SPDX-License-Identifier: Apache-2.0 + +package ghworkflow + +import ( + "slices" + "strings" + + "github.com/sapcc/go-makefile-maker/internal/core" +) + +func helmWorkflow(cfg core.Configuration) { + // https://docs.github.com/en/packages/managing-github-packages-using-github-actions-workflows/publishing-and-installing-a-package-with-github-actions#publishing-a-package-using-an-action + w := newWorkflow("Helm OCI Package GHCR", cfg.GitHubWorkflow.Global.DefaultBranch, nil) + + if w.deleteIf(cfg.GitHubWorkflow.PushHelmChartToGhcr.Path.IsSome() && + strings.HasPrefix(cfg.Metadata.URL, "https://github.com")) { + return + } + + var helmConfig = cfg.GitHubWorkflow.PushHelmChartToGhcr + var chartPath = helmConfig.Path.UnwrapOr(".") + + w.Permissions.Contents = tokenScopeRead + w.Permissions.Packages = tokenScopeWrite + + w.On.WorkflowDispatch.manualTrigger = true + + var helmPackageCmds []string + strategy := cfg.GitHubWorkflow.PushContainerToGhcr.TagStrategy + if !helmConfig.DisableVersioning { + if slices.Contains(strategy, "semver") { + // try to detect a version from the git tags + w.On.Push.Tags = []string{"*"} + helmPackageCmds = append(helmPackageCmds, + `# try to detect a version from the git tags, set APP_VERSION only if this commit has been tagged`, + `APP_VERSION=$(git describe --tags --exact-match ${{ github.sha }} 2>/dev/null || echo "")`, + `if [ -n "$APP_VERSION" ]; then`, + ` VERSION=$(echo -n "$APP_VERSION" | sed -E 's/^v//')`, + `else`, + ` VERSION=$((git describe --tags --abbrev=0 2>/dev/null | sed -E 's/^v//') || echo "")`, + `fi`, + ``, + ) + } + if slices.Contains(strategy, "sha") { + w.On.Push.Branches = []string{cfg.GitHubWorkflow.Global.DefaultBranch} + // use the git sha as version, if no version could be detected from the tags + helmPackageCmds = append(helmPackageCmds, + `# use the git sha as app-version, if no version could be detected from the tags`, + `if [ -z "$APP_VERSION" ]; then`, + ` APP_VERSION=$(echo -n "sha-${{ github.sha }}")`, + `fi`, + `# use the git sha as helm version suffix, version is semver`, + `if [ -z "$VERSION" ] && [ -n "$APP_VERSION" ]; then`, + ` VERSION="$(helm show chart `+chartPath+` | grep -E "^version:" | awk '{print $2}' )+${APP_VERSION:0:11}"`, + `fi`, + ``, + ) + } + } + w.On.PullRequest.Branches = nil + + if !slices.Contains(strategy, "sha") && !slices.Contains(strategy, "semver") { + // Just upload any chart change + w.On.Push.Paths = []string{chartPath + "/**"} + } + + if helmConfig.DependencyUpdate.UnwrapOr(true) { + helmPackageCmds = append(helmPackageCmds, `HELM_ARGS=--dependency-update`) + } + + const registry = "ghcr.io" + j := baseJob("Build and publish Helm Chart OCI", cfg.GitHubWorkflow) + j.Steps[0].With = map[string]any{ + "fetch-depth": 0, // we need the full git history to be able to detect versions from tags + "fetch-tags": true, // we need the tags to be able to detect versions from tags + } + j.addStep(jobStep{ + Name: "Install Helm", + Uses: core.HelmSetupAction, + }) + if helmConfig.Lint.UnwrapOr(true) { + j.addStep(jobStep{ + Name: "Lint Helm Chart", + Run: "helm lint " + chartPath, + }) + } + j.addStep(jobStep{ + Name: "Package Helm Chart", + Run: makeMultilineYAMLString(append( + helmPackageCmds, + `if [ -n "$APP_VERSION" ]; then`, + ` HELM_ARGS="$HELM_ARGS --app-version $APP_VERSION"`, + `fi`, + `if [ -n "$VERSION" ]; then`, + ` HELM_ARGS="$HELM_ARGS --version $VERSION"`, + `fi`, + `echo "Running helm package with $HELM_ARGS"`, + `helm package `+chartPath+` --destination ./chart $HELM_ARGS`, + )), + }) + j.addStep(jobStep{ + Name: "Log in to the Container registry", + Uses: core.DockerLoginAction, + With: map[string]any{ //nolint:gosec // not a hardcoded secret, we are doing templating here + "registry": registry, + "username": "${{ github.actor }}", + "password": "${{ secrets.GITHUB_TOKEN }}", + }, + }) + j.addStep(jobStep{ + Name: "Push Helm Chart to " + registry, + Run: "helm push ./chart/*.tgz oci://" + registry + "/${{ github.repository_owner }}/charts", + }) + w.Jobs = map[string]job{"build-and-push-helm-package": j} + + writeWorkflowToFile(w) +}