Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 121 additions & 0 deletions .github/workflows/helm-lint.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
# Reusable workflow: lint and (optionally) kubeconform-validate Helm charts.
#
# Language-agnostic — works for single-chart repos (chart_glob: "charts/")
# and monorepos with several charts (chart_glob: "charts/*/").
#
# Single-chart caller (sugar-suite / course-workload-estimator):
#
# jobs:
# helm-lint:
# uses: bcit-tlu/.github/.github/workflows/helm-lint.yaml@main
# with:
# chart_glob: "charts/"
# extra_command: bash tests/helm-cdn-rewrite.sh # optional
#
# Monorepo caller (hriv):
#
# jobs:
# helm-lint:
# uses: bcit-tlu/.github/.github/workflows/helm-lint.yaml@main
# with:
# chart_glob: "charts/*/"
# validate_continue_on_error: true
# extra_command: bash scripts/test-helm-chart-regressions.sh

name: Helm lint

on:
workflow_call:
inputs:
chart_glob:
description: "Space/glob-expandable list of chart directories to lint (e.g. 'charts/' or 'charts/*/')."
required: false
type: string
default: "charts/"
kubeconform_version:
description: "kubeconform release to install."
required: false
type: string
default: v0.6.7
validate_continue_on_error:
description: "Treat kubeconform validation failures as non-fatal (schema downloads can be flaky). Strict `helm lint` stays the hard gate."
required: false
type: boolean
default: false
extra_command:
description: "Optional shell command run after validation (e.g. a repo-specific chart test script)."
required: false
type: string
default: ""

permissions:
contents: read

jobs:
lint:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
# Bring the chart sources into the runner.
- uses: actions/checkout@v7

# `helm` is used both to lint and to render charts for kubeconform.
- uses: azure/setup-helm@v5

# kubeconform renders + schema-validates the templated manifests.
- name: Install kubeconform
env:
KUBECONFORM_VERSION: ${{ inputs.kubeconform_version }}
run: |
curl -sSL "https://github.com/yannh/kubeconform/releases/download/${KUBECONFORM_VERSION}/kubeconform-linux-amd64.tar.gz" \
| sudo tar xz -C /usr/local/bin kubeconform

# Cache the downloaded JSON schemas so repeated runs don't re-fetch them
# (keyed on the kubeconform version + Chart.yaml set).
- name: Cache kubeconform schemas
uses: actions/cache@v5
with:
path: /tmp/kubeconform-cache
key: kubeconform-schemas-${{ inputs.kubeconform_version }}-${{ hashFiles('charts/**/Chart.yaml') }}
restore-keys: |
kubeconform-schemas-${{ inputs.kubeconform_version }}-
Comment on lines +80 to +82

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: kubeconform schema cache key is keyed only on Chart.yaml, not chart_glob

The cache key kubeconform-schemas-${{ inputs.kubeconform_version }}-${{ hashFiles('charts/**/Chart.yaml') }} hardcodes the charts/**/Chart.yaml glob, independent of the configurable chart_glob input. This works for both documented callers (single-chart charts/charts/Chart.yaml, and monorepo charts/*/charts/*/Chart.yaml, both matched by charts/**/Chart.yaml). It would only break for a caller whose charts live outside charts/, where hashFiles returns empty and the key collapses to a constant. Not a correctness issue because kubeconform re-downloads any missing schemas and restore-keys provides prefix fallback; noting for awareness in case chart layouts diverge from the two documented shapes.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


# Hard gate: `helm lint` each chart in the glob. Any failure fails CI.
- name: Lint charts (strict gate)
env:
CHART_GLOB: ${{ inputs.chart_glob }}
run: |
set -euo pipefail
for chart in ${CHART_GLOB}; do
echo "::group::helm lint ${chart}"
helm lint "${chart}"
echo "::endgroup::"
done

# kubeconform schema downloads from raw.githubusercontent.com can be flaky
# on shared runners. Callers that want the strict `helm lint` above to be
# the primary gate can set validate_continue_on_error to absorb transient
# download failures here.
- name: Validate rendered charts with kubeconform
continue-on-error: ${{ inputs.validate_continue_on_error }}
env:
CHART_GLOB: ${{ inputs.chart_glob }}
run: |
set -euo pipefail
mkdir -p /tmp/kubeconform-cache
for chart in ${CHART_GLOB}; do
echo "::group::kubeconform ${chart}"
helm template test "${chart}" \
| kubeconform -cache /tmp/kubeconform-cache -strict -summary \
-schema-location default \
-schema-location 'https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/{{.Group}}/{{.ResourceKind}}_{{.ResourceAPIVersion}}.json' \
-ignore-missing-schemas
echo "::endgroup::"
done

# Optional repo-specific check (e.g. a chart regression/CDN-rewrite
# script) run only when the caller supplies `extra_command`.
- name: Run extra command
if: inputs.extra_command != ''
run: ${{ inputs.extra_command }}
Comment on lines +119 to +121

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟨 Reusable workflow input interpolated directly into a shell run block

The extra_command input is expanded directly into the run: script via template interpolation (.github/workflows/helm-lint.yaml:121), so whatever string a caller supplies is executed verbatim as shell. Because the value is substituted before the shell sees it (rather than passed via an environment variable), any shell metacharacters in the input are interpreted, giving arbitrary command execution in the job. Callers of a reusable workflow are generally trusted, so impact is limited, but the pattern is fragile hardening-wise.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

220 changes: 220 additions & 0 deletions .github/workflows/helm-publish.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
# Reusable workflow: package, push, and cosign-sign a Helm chart to an OCI
# registry.
#
# Two version modes:
# * RC publish (from ci.yaml): pass `version` directly (e.g. the
# oci-build rc_version). Empty `version` = nothing to publish → skip.
# * Release publish (from a `release: published` workflow): pass
# `tag_name` + `tag_prefix`; the tag is parsed to a semver `version`.
# A tag that doesn't match the prefix is skipped (useful for monorepo
# matrices where only one component's tag matches).
#
# RC caller (single chart, sugar-suite ci.yaml):
#
# helm-publish:
# needs: [helm-lint, build-sugar-suite, cdn-upload]
# if: github.event_name != 'pull_request' && needs.build-sugar-suite.outputs.rc_version != ''
# uses: bcit-tlu/.github/.github/workflows/helm-publish.yaml@main
# permissions:
# contents: read
# packages: write
# id-token: write
# with:
# chart_dir: charts
# version: ${{ needs.build-sugar-suite.outputs.rc_version }}
# inject_cdn_sha: true
# secrets: inherit
#
# Release caller (monorepo matrix, hriv helm-publish.yaml):
#
# publish:
# strategy:
# matrix:
# component: [frontend, backend, backup]
# uses: bcit-tlu/.github/.github/workflows/helm-publish.yaml@main
# permissions:
# contents: read
# packages: write
# id-token: write
# with:
# chart_dir: charts/${{ matrix.component }}
# tag_name: ${{ inputs.tag_name || github.event.release.tag_name }}
# tag_prefix: ${{ matrix.component }}-v
# ref: ${{ inputs.tag_name || github.event.release.tag_name }}
# update_dependencies: true
# secrets: inherit

name: Helm publish

on:
workflow_call:
inputs:
chart_dir:
description: "Path to the chart directory (e.g. 'charts' or 'charts/frontend')."
required: true
type: string
version:
description: "Explicit chart/app version to publish. When set, tag_name/tag_prefix are ignored."
required: false
type: string
default: ""
tag_name:
description: "Release tag to parse when `version` is empty (e.g. v1.2.3 or backend-v1.2.3)."
required: false
type: string
default: ""
tag_prefix:
description: "Prefix preceding the semver in tag_name (e.g. 'v' or 'backend-v'). A tag not matching is skipped."
required: false
type: string
default: v
ref:
description: "Git ref to checkout (defaults to the triggering commit). Set to the release tag for release publishes."
required: false
type: string
default: ""
inject_cdn_sha:
description: "Inject the short commit SHA into <chart_dir>/values.yaml `cdn.commitSha` before packaging."
required: false
type: boolean
default: false
update_dependencies:
description: "Run `helm dependency update` before packaging (charts with subchart deps)."
required: false
type: boolean
default: false
registry:
description: "OCI registry hosting the chart."
required: false
type: string
default: ghcr.io

permissions:
contents: read

jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write
env:
REGISTRY: ${{ inputs.registry }}
steps:
# Decide what version to publish (and whether to publish at all):
# * explicit `version` wins (RC publishes from ci.yaml);
# * otherwise parse the semver out of `tag_name` using `tag_prefix`;
# * a non-matching tag sets publish=false so the rest of the job is
# skipped (lets monorepo matrices no-op on other components' tags).
- name: Resolve version
id: resolve
shell: bash
env:
VERSION_IN: ${{ inputs.version }}
TAG: ${{ inputs.tag_name }}
PREFIX: ${{ inputs.tag_prefix }}
run: |
set -euo pipefail
if [[ -n "${VERSION_IN}" ]]; then
echo "version=${VERSION_IN}" >> "$GITHUB_OUTPUT"
echo "publish=true" >> "$GITHUB_OUTPUT"
elif [[ "${TAG}" =~ ^${PREFIX}([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then
echo "version=${BASH_REMATCH[1]}" >> "$GITHUB_OUTPUT"
echo "publish=true" >> "$GITHUB_OUTPUT"
else
echo "publish=false" >> "$GITHUB_OUTPUT"
echo "No version input and tag '${TAG}' does not match prefix '${PREFIX}'; skipping."
fi

# Check out the exact ref being published (release tag for releases,
# triggering commit for RC publishes) so the packaged chart matches it.
- uses: actions/checkout@v7
if: steps.resolve.outputs.publish == 'true'
with:
ref: ${{ inputs.ref || github.sha }}

# `helm` packages/pushes the chart; cosign signs the pushed artifact.
- uses: azure/setup-helm@v5
if: steps.resolve.outputs.publish == 'true'

- uses: sigstore/cosign-installer@v4
if: steps.resolve.outputs.publish == 'true'

# cosign + helm each need their own OCI login to the registry.
- name: Cosign login (OCI)
if: steps.resolve.outputs.publish == 'true'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REGISTRY: ${{ env.REGISTRY }}
ACTOR: ${{ github.actor }}
run: |
echo "${GITHUB_TOKEN}" | \
cosign login "${REGISTRY}" \
-u "${ACTOR}" \
--password-stdin

# `helm registry login` is separate from the cosign login above.
- name: Helm login (OCI)
if: steps.resolve.outputs.publish == 'true'
shell: bash
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REGISTRY: ${{ env.REGISTRY }}
ACTOR: ${{ github.actor }}
run: |
echo "${GITHUB_TOKEN}" | \
helm registry login "${REGISTRY}" \
-u "${ACTOR}" \
--password-stdin

# Package the chart at the resolved version, push it to the OCI registry,
# then keyless-sign the pushed digest with cosign.
- name: Package, push & sign chart
if: steps.resolve.outputs.publish == 'true'
shell: bash
env:
VERSION: ${{ steps.resolve.outputs.version }}
CHART_DIR: ${{ inputs.chart_dir }}
INJECT_CDN_SHA: ${{ inputs.inject_cdn_sha }}
UPDATE_DEPS: ${{ inputs.update_dependencies }}
OCI_BASE: oci://${{ env.REGISTRY }}/${{ github.repository }}/charts
IMAGE_BASE: ${{ env.REGISTRY }}/${{ github.repository }}/charts
Comment on lines +181 to +182

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: OCI paths rely on github.repository already being lowercase

OCI_BASE/IMAGE_BASE interpolate ${{ github.repository }} directly (helm-publish.yaml:181-182), and release-retag/oci-build do the same for image refs. GHCR requires lowercase repository paths. This is safe for the bcit-tlu org (already lowercase) and matches the pre-existing oci-build.yaml convention, but any caller repo/org containing uppercase letters would produce an invalid OCI reference. Noting the shared assumption rather than flagging as a bug.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

run: |
set -euo pipefail

CHART_NAME=$(yq '.name' "${CHART_DIR}/Chart.yaml")
DEST_DIR="/tmp/charts"
mkdir -p "${DEST_DIR}"

# Inject short commit SHA for CDN immutable path (matched by upload prefix).
if [[ "${INJECT_CDN_SHA}" == "true" ]]; then
SHORT_SHA="$(git rev-parse --short HEAD)"
yq -i ".cdn.commitSha = \"${SHORT_SHA}\"" "${CHART_DIR}/values.yaml"
fi
Comment on lines +190 to +194

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 cdn.commitSha injected without the 'g' prefix used by the RC version

helm-publish injects cdn.commitSha as the raw git rev-parse --short HEAD (helm-publish.yaml:192-193), whereas oci-build.yaml:214 prefixes the short SHA with g for the RC version segment. These are intentionally different values (the RC version needs the g for SemVer §9 numeric-identifier validity, while the CDN path presumably uses the plain short SHA to match the upload prefix). Worth confirming the CDN upload destination_prefix in caller repos uses the plain (un-prefixed) short SHA so the chart's cdn.commitSha resolves to a path that actually exists.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The g was added to prevent release-please actions being blocked by short_sha's that contain only numbers.


if [[ "${UPDATE_DEPS}" == "true" ]]; then
helm dependency update "${CHART_DIR}" || true
fi

echo "::group::${CHART_NAME} ${VERSION}"
helm package "${CHART_DIR}" \
--version "${VERSION}" \
--app-version "${VERSION}" \
-d "${DEST_DIR}"

PUSH_OUT=$(helm push \
"${DEST_DIR}/${CHART_NAME}-${VERSION}.tgz" \
"${OCI_BASE}" 2>&1) || { echo "${PUSH_OUT}"; exit 1; }
echo "${PUSH_OUT}"

# Tolerant digest parse: case-insensitive, allows leading whitespace.
DIGEST=$(printf '%s\n' "${PUSH_OUT}" \
| awk 'tolower($1)=="digest:"{print $2; exit}')
if [[ -z "${DIGEST}" ]]; then
echo "::error::helm push for ${CHART_NAME} succeeded but no digest found in output (helm output format may have changed)"
exit 1
fi

cosign sign --yes "${IMAGE_BASE}/${CHART_NAME}@${DIGEST}"
echo "::endgroup::"
2 changes: 1 addition & 1 deletion .github/workflows/oci-build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@ jobs:
${{ env.REGISTRY }}/${{ github.repository }}/${{ inputs.image_name }}:${{ steps.version.outputs.next }}-rc.${{ steps.version.outputs.ts }}.${{ steps.version.outputs.short }}

# ── Sign with Cosign (keyless / Sigstore) ────────────────
- uses: sigstore/cosign-installer@v4.1.2
- uses: sigstore/cosign-installer@v4
if: steps.changes.outputs.changed == 'true' && github.event_name != 'pull_request'

- name: Sign image
Expand Down
Loading