From 0df468d68085b66390f67d410fd7d7f5aa446fc2 Mon Sep 17 00:00:00 2001 From: Musa Misto <64855513+MusaMisto@users.noreply.github.com> Date: Sun, 14 Jun 2026 15:31:09 +0300 Subject: [PATCH 1/9] fix(helm-generic): parameterize migration job, namespace checkpoints, drop dead ingress logic The helm-generic composite action is used inside the generic-chart-helm (ingress-nginx) and generic-gateway-helm-template (Gateway API) reusable workflows. Three fixes, all verified behavior-preserving for existing callers: #1 Migration Job was hardcoded to one app (`dotnet SW.Shanap.Web.dll --migrate`, `connection-string`, `ConnectionStrings__ShanapDb`), so any other app (e.g. mealivery) running the "generic" workflow executed the wrong entrypoint. Added init_job_command / init_job_secret_key / init_job_connstring_env / init_job_environment_env inputs (action + both workflows) that coalesce to the historical defaults when empty -> identical Job for existing callers, correct entrypoint for new ones. #2 The action wrote CHECKPOINT_1_STATUS/CHECKPOINT_2_STATUS into $GITHUB_ENV, colliding with the same names owned by the calling workflows (different meanings). Renamed to HELM_GENERIC_KUBECONFIG_STATUS / _DEPLOY_STATUS so the workflows' checkpoint summaries are no longer clobbered. #3 The action always injected `--set ingress.paths[0]=/`. Removed it: the ingress chart (s9genericchart) defaults ingress.paths to ["/"], and the gateway chart (s9genericchart-v2) has no ingress template at all, so removal renders identical manifests in both workflows (verified via helm template). Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/actions/helm-generic/action.yml | 94 +++++++++++-------- .github/workflows/generic-chart-helm.yml | 30 +++++- .../generic-gateway-helm-template.yml | 26 +++++ 3 files changed, 108 insertions(+), 42 deletions(-) diff --git a/.github/actions/helm-generic/action.yml b/.github/actions/helm-generic/action.yml index cd8f92e..1378933 100644 --- a/.github/actions/helm-generic/action.yml +++ b/.github/actions/helm-generic/action.yml @@ -37,16 +37,6 @@ inputs: description: "Raw extra args appended to helm upgrade (e.g. --debug)" required: false default: '' - ingress_paths: - description: |- - YAML list of ingress paths. Provide as a multi-line YAML sequence, e.g.: - - / - - /api - - /docs - Defaults to just the root path '/'. Each entry becomes --set ingress.paths[index]=. - required: false - default: |- - - / kubeconfig_data: description: |- Raw kubeconfig YAML or base64-encoded kubeconfig data. This replaces the dynamic secret (_KUBECONFIG). @@ -64,8 +54,34 @@ inputs: default: '' init_job_secret_name: description: |- - Name of the Kubernetes Secret (in the target namespace) whose 'connection-string' key is - injected as ConnectionStrings__ShanapDb into the migration Job. Required when init_job_image is set. + Name of the Kubernetes Secret (in the target namespace) whose key (see init_job_secret_key) + is injected as the connection-string env var (see init_job_connstring_env) into the migration + Job. Required when init_job_image is set. + required: false + default: '' + init_job_command: + description: |- + Command (and args) the migration Job container runs, as a space-separated string. + Converted to a JSON exec array, e.g. "dotnet App.dll --migrate" -> ["dotnet","App.dll","--migrate"]. + Leave empty to keep the historical default ('dotnet SW.Shanap.Web.dll --migrate'). + required: false + default: '' + init_job_secret_key: + description: |- + Key within init_job_secret_name whose value holds the DB connection string. + Leave empty to keep the historical default ('connection-string'). + required: false + default: '' + init_job_connstring_env: + description: |- + Name of the environment variable the connection string is injected into inside the migration Job. + Leave empty to keep the historical default ('ConnectionStrings__ShanapDb'). + required: false + default: '' + init_job_environment_env: + description: |- + Name of the environment variable carrying the environment name (the 'environment' input) inside + the migration Job. Leave empty to keep the historical default ('ASPNETCORE_ENVIRONMENT'). required: false default: '' environment: @@ -85,8 +101,8 @@ runs: run: | echo "::notice title=☸️ [HELM] Deploy Chart::App: ${{ inputs.app_name }}, chart: ${{ inputs.chart }}, namespace: ${{ inputs.namespace }}, env: ${{ inputs.environment }}" echo "⚙️ [HELM] Chart repo: ${{ inputs.chart_repo != '' && inputs.chart_repo || 'OCI (inline)' }}, timeout: ${{ inputs.helm_timeout }}" - echo "CHECKPOINT_1_STATUS=⏳ Pending" >> "$GITHUB_ENV" - echo "CHECKPOINT_2_STATUS=⏳ Pending" >> "$GITHUB_ENV" + echo "HELM_GENERIC_KUBECONFIG_STATUS=⏳ Pending" >> "$GITHUB_ENV" + echo "HELM_GENERIC_DEPLOY_STATUS=⏳ Pending" >> "$GITHUB_ENV" - name: Derive sanitized branch name id: branch @@ -126,7 +142,7 @@ runs: fi chmod 600 kubeconfig.yaml echo "kubeconfig_path=${{ github.workspace }}/kubeconfig.yaml" >> "$GITHUB_OUTPUT" - echo "CHECKPOINT_1_STATUS=✅ PASSED" >> "$GITHUB_ENV" + echo "HELM_GENERIC_KUBECONFIG_STATUS=✅ PASSED" >> "$GITHUB_ENV" echo "::notice title=✅ [HELM] Kubeconfig configured::namespace: ${{ inputs.namespace }}" echo "::endgroup::" @@ -152,12 +168,26 @@ runs: NAMESPACE: ${{ inputs.namespace }} INIT_JOB_IMAGE: ${{ inputs.init_job_image }} INIT_JOB_SECRET_NAME: ${{ inputs.init_job_secret_name }} + INIT_JOB_COMMAND: ${{ inputs.init_job_command }} + INIT_JOB_SECRET_KEY: ${{ inputs.init_job_secret_key }} + INIT_JOB_CONNSTRING_ENV: ${{ inputs.init_job_connstring_env }} + INIT_JOB_ENVIRONMENT_ENV: ${{ inputs.init_job_environment_env }} + ENVIRONMENT: ${{ inputs.environment }} run: | set -euo pipefail JOB_NAME="${APP_NAME}-db-migrate-${GITHUB_RUN_NUMBER}" - echo "Applying migration Job: $JOB_NAME" + # Coalesce to the historical defaults so callers that don't override keep identical behavior. + CMD_STR="${INIT_JOB_COMMAND:-dotnet SW.Shanap.Web.dll --migrate}" + SECRET_KEY="${INIT_JOB_SECRET_KEY:-connection-string}" + CONNSTRING_ENV="${INIT_JOB_CONNSTRING_ENV:-ConnectionStrings__ShanapDb}" + ENVIRONMENT_ENV="${INIT_JOB_ENVIRONMENT_ENV:-ASPNETCORE_ENVIRONMENT}" + + # Build a JSON exec array from the space-separated command string. + CMD_JSON=$(jq -cn --arg s "$CMD_STR" '$s | split(" ") | map(select(length > 0))') + + echo "Applying migration Job: $JOB_NAME (command: ${CMD_JSON})" kubectl apply -f - <> "$GITHUB_ENV" + echo "HELM_GENERIC_DEPLOY_STATUS=✅ PASSED" >> "$GITHUB_ENV" echo "::notice title=✅ [HELM] Helm deploy complete::App: ${APP_NAME}, namespace: ${NAMESPACE}" echo "::endgroup::" @@ -285,4 +299,4 @@ runs: if: failure() shell: bash run: | - echo "::error title=❌ [HELM] Deploy failed::App '${{ inputs.app_name }}' in namespace '${{ inputs.namespace }}'. Checkpoints — 1) Kubeconfig: ${CHECKPOINT_1_STATUS:-⏭️ Not reached} | 2) Helm deploy: ${CHECKPOINT_2_STATUS:-⏭️ Not reached}." + echo "::error title=❌ [HELM] Deploy failed::App '${{ inputs.app_name }}' in namespace '${{ inputs.namespace }}'. Checkpoints — 1) Kubeconfig: ${HELM_GENERIC_KUBECONFIG_STATUS:-⏭️ Not reached} | 2) Helm deploy: ${HELM_GENERIC_DEPLOY_STATUS:-⏭️ Not reached}." diff --git a/.github/workflows/generic-chart-helm.yml b/.github/workflows/generic-chart-helm.yml index b6b7d32..1ac9f76 100644 --- a/.github/workflows/generic-chart-helm.yml +++ b/.github/workflows/generic-chart-helm.yml @@ -130,12 +130,34 @@ on: type: string init-job-secret-name: description: |- - Name of the Kubernetes Secret (in the target namespace) containing a 'connection-string' - key with the doadmin credentials. Required when init-job-image is set. + Name of the Kubernetes Secret (in the target namespace) containing the connection-string + key (see init-job-secret-key) with the doadmin credentials. Required when init-job-image is set. Example: mealivery-db-admin-secret required: false default: '' type: string + init-job-command: + description: |- + Command (space-separated) the migration Job runs, e.g. 'dotnet Mealivery.Api.dll --migrate'. + Leave empty to keep the historical default ('dotnet SW.Shanap.Web.dll --migrate'). + required: false + default: '' + type: string + init-job-secret-key: + description: Key in init-job-secret-name holding the connection string (default 'connection-string'). + required: false + default: '' + type: string + init-job-connstring-env: + description: Env var name the connection string is injected into (default 'ConnectionStrings__ShanapDb'). + required: false + default: '' + type: string + init-job-environment-env: + description: Env var name carrying the environment name in the migration Job (default 'ASPNETCORE_ENVIRONMENT'). + required: false + default: '' + type: string secrets: # Container registry secrets @@ -492,6 +514,10 @@ jobs: kubeconfig_data: ${{ secrets.kubeconfig }} init_job_image: ${{ inputs.init-job-image }} init_job_secret_name: ${{ inputs.init-job-secret-name }} + init_job_command: ${{ inputs.init-job-command }} + init_job_secret_key: ${{ inputs.init-job-secret-key }} + init_job_connstring_env: ${{ inputs.init-job-connstring-env }} + init_job_environment_env: ${{ inputs.init-job-environment-env }} environment: ${{ inputs.environment }} - name: Confirm Helm deployed diff --git a/.github/workflows/generic-gateway-helm-template.yml b/.github/workflows/generic-gateway-helm-template.yml index 8078796..e7b0778 100644 --- a/.github/workflows/generic-gateway-helm-template.yml +++ b/.github/workflows/generic-gateway-helm-template.yml @@ -255,6 +255,28 @@ on: required: false default: '' type: string + init-job-command: + description: |- + Command (space-separated) the migration Job runs. Leave empty to keep the + historical default ('dotnet SW.Shanap.Web.dll --migrate'). + required: false + default: '' + type: string + init-job-secret-key: + description: Key in init-job-secret-name holding the connection string (default 'connection-string'). + required: false + default: '' + type: string + init-job-connstring-env: + description: Env var name the connection string is injected into (default 'ConnectionStrings__ShanapDb'). + required: false + default: '' + type: string + init-job-environment-env: + description: Env var name carrying the environment name in the migration Job (default 'ASPNETCORE_ENVIRONMENT'). + required: false + default: '' + type: string secrets: registry-username: @@ -882,6 +904,10 @@ jobs: kubeconfig_data: ${{ secrets.kubeconfig }} init_job_image: ${{ inputs.init-job-image }} init_job_secret_name: ${{ inputs.init-job-secret-name }} + init_job_command: ${{ inputs.init-job-command }} + init_job_secret_key: ${{ inputs.init-job-secret-key }} + init_job_connstring_env: ${{ inputs.init-job-connstring-env }} + init_job_environment_env: ${{ inputs.init-job-environment-env }} environment: ${{ inputs.environment }} - name: Confirm Helm deployed From c2d4c86ad035540cce19593f8b49658a57d0aa83 Mon Sep 17 00:00:00 2001 From: Musa Misto <64855513+MusaMisto@users.noreply.github.com> Date: Sun, 14 Jun 2026 15:33:58 +0300 Subject: [PATCH 2/9] fix(helm-generic): correct docs, drop dead inputs, ensure namespace before migration #7 cleanups: - Remove dead inputs `registry_profile` and `version` (declared but never used; neither calling workflow passes them, so removal is contract-safe). - Remove the redundant `app.name` set from both workflows' extra_set_values; the action already sets it canonically from the required app_name input (identical value, verified via helm template). app.version is left in place since the workflows intentionally override the action's branch-based value with the semver. - Fix README.md and AGENTS.md, which incorrectly described helm-generic as a "checkout + lint + package + push" CI action; it is a deployer. Namespace ordering fix: - The migration Job ran before the Helm step's --create-namespace, so a first deploy to a fresh namespace failed at the Job apply. Ensure the namespace exists first via the idempotent `kubectl create --dry-run=client | apply` pattern already used by generic-gateway-helm-template. No-op when the namespace already exists. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/actions/helm-generic/action.yml | 13 +++++-------- .github/workflows/generic-chart-helm.yml | 1 - .github/workflows/generic-gateway-helm-template.yml | 1 - AGENTS.md | 2 +- README.md | 2 +- 5 files changed, 7 insertions(+), 12 deletions(-) diff --git a/.github/actions/helm-generic/action.yml b/.github/actions/helm-generic/action.yml index 1378933..31551a0 100644 --- a/.github/actions/helm-generic/action.yml +++ b/.github/actions/helm-generic/action.yml @@ -2,17 +2,9 @@ name: 'Helm Deploy' description: 'Deploy a Helm chart to a Kubernetes cluster' inputs: - registry_profile: - description: "Profile code for dynamic kubeconfig/registry vars & secrets (e.g., S9, WISEWELL)" - required: false - default: S9 app_name: description: "Application name (Helm release name)" required: true - version: - description: "Logical version label (may be used in image tag selection)" - required: false - default: staging namespace: description: "Kubernetes namespace to deploy into" required: true @@ -178,6 +170,11 @@ runs: JOB_NAME="${APP_NAME}-db-migrate-${GITHUB_RUN_NUMBER}" + # Ensure the target namespace exists before applying the Job. The Helm step uses + # --create-namespace, but it runs AFTER this Job, so on a first deploy to a fresh + # namespace the Job apply would otherwise fail. Idempotent (no-op if it already exists). + kubectl create namespace "${NAMESPACE}" --dry-run=client -o yaml | kubectl apply -f - + # Coalesce to the historical defaults so callers that don't override keep identical behavior. CMD_STR="${INIT_JOB_COMMAND:-dotnet SW.Shanap.Web.dll --migrate}" SECRET_KEY="${INIT_JOB_SECRET_KEY:-connection-string}" diff --git a/.github/workflows/generic-chart-helm.yml b/.github/workflows/generic-chart-helm.yml index 1ac9f76..efafb52 100644 --- a/.github/workflows/generic-chart-helm.yml +++ b/.github/workflows/generic-chart-helm.yml @@ -498,7 +498,6 @@ jobs: # Translate your previous comma-joined --set into line-based entries extra_set_values: | image.repo=${{ env.CONTAINER_REGISTRY }} - app.name=${{ inputs.app-name }} app.version=${{ needs.version.outputs.version }} image.overrideName=${{ inputs.app-name }} image.overrideVersion=${{ needs.version.outputs.version }} diff --git a/.github/workflows/generic-gateway-helm-template.yml b/.github/workflows/generic-gateway-helm-template.yml index e7b0778..562c656 100644 --- a/.github/workflows/generic-gateway-helm-template.yml +++ b/.github/workflows/generic-gateway-helm-template.yml @@ -890,7 +890,6 @@ jobs: extra_args: --wait extra_set_values: | image.repo=${{ env.CONTAINER_REGISTRY }} - app.name=${{ inputs.app-name || github.repository }} app.version=${{ needs.version.outputs.version }} image.overrideName=${{ inputs.app-name }} image.overrideVersion=${{ needs.version.outputs.version }} diff --git a/AGENTS.md b/AGENTS.md index 23987f3..9de2ca3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -272,7 +272,7 @@ steps: ### Helm - `helm-deploy` — Profile-based deploy (`registry_profile` selects dynamic secrets). Supports `init_job_image` for database migration Jobs before deploy. - `helm-deploy-s9generic` — Deploy using `s9genericchart` from `https://charts.sf9.io`. Handles `set-values` (`--set`) and `set-string-values` (`--set-string`) separately. -- `helm-generic` — Checkout + lint + package + push (single composite for chart CI). +- `helm-generic` — Deploy a Helm chart via `helm upgrade --install`, with an optional pre-deploy DB migration Job (`init_job_image` + `init_job_command`). Internal building block for the `generic-chart-helm` (ingress-nginx) and `generic-gateway-helm-template` (Gateway API) reusable workflows. - `helm-package-push` — Package a chart and push to OCI registry. ### .NET diff --git a/README.md b/README.md index cb96008..3d927d4 100644 --- a/README.md +++ b/README.md @@ -759,7 +759,7 @@ uses: simplify9/.github/.github/actions/@main |---|---|---| | `helm-deploy` | Profile-based deploy; supports `init_job_image` for pre-deploy DB migration Jobs | `app_name`, `namespace`, `kubeconfig_data` | | `helm-deploy-s9generic` | Deploy `s9genericchart` from `https://charts.sf9.io`; handles `set-values` (`--set`) and `set-string-values` (`--set-string`) separately | `chart-name`, `chart-version`, `kubeconfig` | -| `helm-generic` | Checkout + Helm lint + package + push in a single composite | `app-name`, `version` | +| `helm-generic` | Deploy a Helm chart (`helm upgrade --install`) with optional pre-deploy DB migration Job. Used by the `generic-chart-helm` and `generic-gateway-helm-template` reusable workflows | `app_name`, `namespace`, `kubeconfig_data` | | `helm-package-push` | Package chart and push to OCI registry | `chart-path`, `chart-name`, `chart-version` | ### .NET From 2b272f555cae447648b6ace8f65d303d26030946 Mon Sep 17 00:00:00 2001 From: Musa Misto <64855513+MusaMisto@users.noreply.github.com> Date: Sun, 14 Jun 2026 15:38:51 +0300 Subject: [PATCH 3/9] fix(helm-generic): harden kubeconfig handling, drop wasted checkout, pin kubectl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #5 kubeconfig handling: - Write the decoded kubeconfig to $RUNNER_TEMP instead of the workspace, so the credential is no longer left in the checked-out tree, and add an `if: always()` cleanup step that removes it (matters on self-hosted/reused runners). - Adopt the generic-gateway-helm-template workflow's decoder (base64 with raw fallback) and add a fail-fast apiVersion validation. Behavior-preserving for the two real input forms (base64 secret, raw YAML) — both decode to a semantically identical kubeconfig — and now rejects malformed input with a clear message instead of letting Helm fail later. #6: - Remove `actions/checkout@v6`: the deploy job uses no repo files (chart comes from chart_repo, image from the registry); the only workspace reference was the old kubeconfig path, now in RUNNER_TEMP. Updated the chart/chart_repo input docs to note local chart paths are not supported. - Pin kubectl to v1.34.0 instead of 'latest' for reproducibility (only basic core-resource ops are used, so cluster skew is not a concern; documented as bumpable to track the cluster's minor version). Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/actions/helm-generic/action.yml | 40 +++++++++++++++++++------ 1 file changed, 31 insertions(+), 9 deletions(-) diff --git a/.github/actions/helm-generic/action.yml b/.github/actions/helm-generic/action.yml index 31551a0..252350b 100644 --- a/.github/actions/helm-generic/action.yml +++ b/.github/actions/helm-generic/action.yml @@ -9,11 +9,11 @@ inputs: description: "Kubernetes namespace to deploy into" required: true chart: - description: "Chart name (local or from repo)" + description: "Chart name, resolved from chart_repo (this action does not check out the repo, so local chart paths are not supported)" required: false default: s9genericchart chart_repo: - description: "Helm repo URL providing the chart (ignored if chart is a path)" + description: "Helm repo URL providing the chart" required: false default: "https://charts.sf9.io" helm_timeout: @@ -86,8 +86,6 @@ inputs: runs: using: 'composite' steps: - - uses: actions/checkout@v6 - - name: Announce Helm deployment shell: bash run: | @@ -127,13 +125,24 @@ runs: echo "kubeconfig_data input is required but empty." >&2 exit 1 fi + # Write to RUNNER_TEMP (not the workspace) so the credential is not left in the + # checked-out tree. Accept raw kubeconfig YAML or base64-encoded data, mirroring the + # generic-gateway-helm-template workflow's decoder: prefer raw when it already looks + # like a kubeconfig, otherwise base64-decode, falling back to raw. + KCFG_PATH="${RUNNER_TEMP}/kubeconfig.yaml" if echo "$DATA" | grep -q 'apiVersion:'; then - printf "%s" "$DATA" > kubeconfig.yaml + printf '%s' "$DATA" > "$KCFG_PATH" + elif printf '%s' "$DATA" | base64 -d > "$KCFG_PATH" 2>/dev/null; then + : # successfully decoded base64 else - echo "$DATA" | base64 -d > kubeconfig.yaml || { echo "Failed to base64 decode kubeconfig_data input" >&2; exit 1; } + printf '%s' "$DATA" > "$KCFG_PATH" + fi + if ! grep -q 'apiVersion:' "$KCFG_PATH"; then + echo "kubeconfig_data did not resolve to a valid kubeconfig (no apiVersion found)." >&2 + exit 1 fi - chmod 600 kubeconfig.yaml - echo "kubeconfig_path=${{ github.workspace }}/kubeconfig.yaml" >> "$GITHUB_OUTPUT" + chmod 600 "$KCFG_PATH" + echo "kubeconfig_path=${KCFG_PATH}" >> "$GITHUB_OUTPUT" echo "HELM_GENERIC_KUBECONFIG_STATUS=✅ PASSED" >> "$GITHUB_ENV" echo "::notice title=✅ [HELM] Kubeconfig configured::namespace: ${{ inputs.namespace }}" echo "::endgroup::" @@ -150,7 +159,10 @@ runs: - name: Install kubectl uses: azure/setup-kubectl@v5 with: - version: 'latest' + # Pinned for reproducibility (avoids a new kubectl silently changing CI behavior). + # Only basic core-resource ops are used here (apply/wait/get/create), so exact skew + # with the cluster is not critical; bump this to track your cluster's minor version. + version: 'v1.34.0' - name: Run DB migration Job (init container equivalent) if: ${{ inputs.init_job_image != '' }} @@ -297,3 +309,13 @@ runs: shell: bash run: | echo "::error title=❌ [HELM] Deploy failed::App '${{ inputs.app_name }}' in namespace '${{ inputs.namespace }}'. Checkpoints — 1) Kubeconfig: ${HELM_GENERIC_KUBECONFIG_STATUS:-⏭️ Not reached} | 2) Helm deploy: ${HELM_GENERIC_DEPLOY_STATUS:-⏭️ Not reached}." + + - name: Clean up kubeconfig + if: always() + shell: bash + env: + KCFG_PATH: ${{ steps.kube.outputs.kubeconfig_path }} + run: | + # Remove the decoded kubeconfig so the credential does not linger (matters on + # self-hosted/reused runners). No-op if the step never produced a path. + [ -n "${KCFG_PATH:-}" ] && rm -f "$KCFG_PATH" || true From fcba18368541f4b12eeba2abf8d66201b3cf0301 Mon Sep 17 00:00:00 2001 From: Musa Misto <64855513+MusaMisto@users.noreply.github.com> Date: Sun, 14 Jun 2026 16:01:29 +0300 Subject: [PATCH 4/9] refactor(helm-generic): doc accuracy, env-binding, arg arrays, atomic + robustness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the cold-review findings M1-M4 and m1-m5 (SHA-pinning intentionally deferred): M1 - Rewrite stale input docs: kubeconfig_data no longer references the removed secret concept; init_job_image no longer hardcodes "--migrate"/EF Core/doadmin language and now points at init_job_command. M2 - Reword the leftover "BASE_SET_ARGS now only…/modified input description" comment. M3 - Bind ${{ inputs.* }} to env: in the Announce, kubeconfig-notice, Set KUBECONFIG and Report-failure steps (GitHub injection-hardening guidance); clears shellcheck SC2296 noise. Behavior-identical. M4 - Assemble extra_set_values and extra_args as bash arrays instead of unquoted word-split strings, so --set values containing spaces stay intact. Token stream is identical for current (space-free) callers. m1 - Add `atomic` input (default 'true') -> pass --atomic so a failed deploy rolls back automatically. (Behavior change: failed upgrades now roll back.) m2 - Migration wait now polls for Complete/Failed and surfaces a failed Job immediately instead of blocking the full 5m timeout. m3 - Show-status pod selector tries app.kubernetes.io/instance= (gateway chart) then falls back to app= (ingress chart), so it matches both. m4 - Rename action to 'Helm Generic Deploy' and add author: Simplify9. m5 - generic-gateway auto-onboard step now traps EXIT to remove its decoded kubeconfig from RUNNER_TEMP. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/actions/helm-generic/action.yml | 101 ++++++++++++------ .../generic-gateway-helm-template.yml | 3 + 2 files changed, 74 insertions(+), 30 deletions(-) diff --git a/.github/actions/helm-generic/action.yml b/.github/actions/helm-generic/action.yml index 252350b..526649f 100644 --- a/.github/actions/helm-generic/action.yml +++ b/.github/actions/helm-generic/action.yml @@ -1,5 +1,6 @@ -name: 'Helm Deploy' +name: 'Helm Generic Deploy' description: 'Deploy a Helm chart to a Kubernetes cluster' +author: 'Simplify9' inputs: app_name: @@ -29,19 +30,22 @@ inputs: description: "Raw extra args appended to helm upgrade (e.g. --debug)" required: false default: '' + atomic: + description: "If 'true', pass --atomic to helm upgrade so a failed deploy is rolled back automatically (implies --wait)." + required: false + default: 'true' kubeconfig_data: description: |- - Raw kubeconfig YAML or base64-encoded kubeconfig data. This replaces the dynamic secret (_KUBECONFIG). - IMPORTANT: Passing sensitive data as a plain input may expose it in logs. Prefer keeping this workflow reusable only - in controlled contexts. Provide either the full YAML (containing apiVersion, clusters, users, etc.) or a base64 string - that decodes to the kubeconfig file contents. + Kubeconfig used to reach the target cluster, as either raw YAML (containing apiVersion, clusters, + users, contexts, ...) or a base64-encoded string of that YAML. + Provide it from a secret; do not pass cluster credentials as a plaintext literal. required: true init_job_image: description: |- - If set, a Kubernetes Job is created before the Helm deploy that runs this image with the - argument '--migrate'. The Job uses the connection string from init_job_secret_name to run - EF Core migrations with elevated (doadmin) credentials before the main container starts. - Example: registry.digitalocean.com/mealiverycr/mealivery-api:latest + If set, a Kubernetes Job runs this image to completion before the Helm deploy (e.g. for database + migrations). The command it runs is controlled by init_job_command, and a connection string is + injected from init_job_secret_name. Leave empty to skip the pre-deploy Job. + Example: registry.digitalocean.com/acme/api:latest required: false default: '' init_job_secret_name: @@ -88,9 +92,16 @@ runs: steps: - name: Announce Helm deployment shell: bash + env: + APP_NAME: ${{ inputs.app_name }} + CHART: ${{ inputs.chart }} + NAMESPACE: ${{ inputs.namespace }} + ENVIRONMENT: ${{ inputs.environment }} + CHART_REPO: ${{ inputs.chart_repo }} + HELM_TIMEOUT: ${{ inputs.helm_timeout }} run: | - echo "::notice title=☸️ [HELM] Deploy Chart::App: ${{ inputs.app_name }}, chart: ${{ inputs.chart }}, namespace: ${{ inputs.namespace }}, env: ${{ inputs.environment }}" - echo "⚙️ [HELM] Chart repo: ${{ inputs.chart_repo != '' && inputs.chart_repo || 'OCI (inline)' }}, timeout: ${{ inputs.helm_timeout }}" + echo "::notice title=☸️ [HELM] Deploy Chart::App: ${APP_NAME}, chart: ${CHART}, namespace: ${NAMESPACE}, env: ${ENVIRONMENT}" + echo "⚙️ [HELM] Chart repo: ${CHART_REPO:-OCI (inline)}, timeout: ${HELM_TIMEOUT}" echo "HELM_GENERIC_KUBECONFIG_STATUS=⏳ Pending" >> "$GITHUB_ENV" echo "HELM_GENERIC_DEPLOY_STATUS=⏳ Pending" >> "$GITHUB_ENV" @@ -117,6 +128,7 @@ runs: shell: bash env: KUBECONFIG_DATA: ${{ inputs.kubeconfig_data }} + NAMESPACE: ${{ inputs.namespace }} run: | echo "::group::☸️ [CHECKPOINT 1/2] Process Kubeconfig" set -euo pipefail @@ -144,12 +156,14 @@ runs: chmod 600 "$KCFG_PATH" echo "kubeconfig_path=${KCFG_PATH}" >> "$GITHUB_OUTPUT" echo "HELM_GENERIC_KUBECONFIG_STATUS=✅ PASSED" >> "$GITHUB_ENV" - echo "::notice title=✅ [HELM] Kubeconfig configured::namespace: ${{ inputs.namespace }}" + echo "::notice title=✅ [HELM] Kubeconfig configured::namespace: ${NAMESPACE}" echo "::endgroup::" - name: Set KUBECONFIG env shell: bash - run: echo "KUBECONFIG=${{ steps.kube.outputs.kubeconfig_path }}" >> "$GITHUB_ENV" + env: + KUBECONFIG_PATH: ${{ steps.kube.outputs.kubeconfig_path }} + run: echo "KUBECONFIG=${KUBECONFIG_PATH}" >> "$GITHUB_ENV" - name: Install Helm uses: azure/setup-helm@v5 @@ -228,17 +242,24 @@ runs: EOF echo "Waiting for migration Job to complete (timeout 5m)..." - kubectl wait job/"${JOB_NAME}" \ - --for=condition=complete \ - --namespace="${NAMESPACE}" \ - --timeout=5m || { - echo "Migration Job failed. Fetching logs:" + # Poll for Complete or Failed so a failed Job surfaces immediately instead of blocking + # the full timeout (kubectl wait --for=condition=complete does not return on failure). + DEADLINE=$((SECONDS + 300)) + while true; do + COMPLETE=$(kubectl get job "${JOB_NAME}" -n "${NAMESPACE}" -o jsonpath='{.status.conditions[?(@.type=="Complete")].status}' 2>/dev/null || true) + FAILED=$(kubectl get job "${JOB_NAME}" -n "${NAMESPACE}" -o jsonpath='{.status.conditions[?(@.type=="Failed")].status}' 2>/dev/null || true) + if [ "$COMPLETE" = "True" ]; then + echo "Migration Job completed successfully." + break + fi + if [ "$FAILED" = "True" ] || [ "$SECONDS" -ge "$DEADLINE" ]; then + echo "Migration Job failed or timed out. Fetching logs:" POD=$(kubectl get pods -n "${NAMESPACE}" -l job-name="${JOB_NAME}" -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || true) [ -n "$POD" ] && kubectl logs -n "${NAMESPACE}" "$POD" || true exit 1 - } - - echo "Migration Job completed successfully." + fi + sleep 5 + done - name: Helm upgrade / install shell: bash @@ -250,6 +271,7 @@ runs: HELM_TIMEOUT: ${{ inputs.helm_timeout }} EXTRA_SET_VALUES: ${{ inputs.extra_set_values }} EXTRA_ARGS: ${{ inputs.extra_args }} + ATOMIC: ${{ inputs.atomic }} BRANCH: ${{ steps.branch.outputs.name }} run: | echo "::group::☸️ [CHECKPOINT 2/2] Helm Upgrade / Install" @@ -263,31 +285,40 @@ runs: CHART_REF="$CHART" fi - # BASE_SET_ARGS now only covers mandatory intrinsic values; all other chart settings must be passed via - # extra_set_values (one per line) or extra_args. See modified input description for guidance. + # Intrinsic values the action always sets; all other chart settings come from + # extra_set_values (one "key=value" per line) or extra_args. BASE_SET_ARGS=( --set app.name="${APP_NAME}" --set app.version="github-${BRANCH}-${GITHUB_RUN_NUMBER}" ) - EXTRA_SET_ARGS="" + # Build "--set key=value" pairs as an array so values containing spaces stay intact + # (an unquoted string would word-split them). + EXTRA_SET_ARGS=() if [ -n "${EXTRA_SET_VALUES// /}" ]; then while IFS= read -r line; do - [ -z "${line}" ] && continue KV=$(echo "$line" | sed 's/^ *//;s/ *$//') [ -z "$KV" ] && continue - EXTRA_SET_ARGS+=" --set $KV" + EXTRA_SET_ARGS+=( --set "$KV" ) done <<< "$EXTRA_SET_VALUES" fi + # extra_args is raw CLI text (e.g. "--debug --wait"); split into argv tokens. + read -ra EXTRA_ARGS_ARR <<< "${EXTRA_ARGS:-}" + + ATOMIC_ARGS=() + if [ "${ATOMIC}" = "true" ]; then + ATOMIC_ARGS+=( --atomic ) + fi + echo "Executing Helm upgrade with base settings plus extras..." helm upgrade "${APP_NAME}" "$CHART_REF" \ --install \ --namespace "${NAMESPACE}" \ --create-namespace \ - "${BASE_SET_ARGS[@]}" ${EXTRA_SET_ARGS} \ + "${BASE_SET_ARGS[@]}" "${EXTRA_SET_ARGS[@]}" \ --timeout "${HELM_TIMEOUT}" \ - ${EXTRA_ARGS} + "${ATOMIC_ARGS[@]}" "${EXTRA_ARGS_ARR[@]}" echo "HELM_GENERIC_DEPLOY_STATUS=✅ PASSED" >> "$GITHUB_ENV" echo "::notice title=✅ [HELM] Helm deploy complete::App: ${APP_NAME}, namespace: ${NAMESPACE}" echo "::endgroup::" @@ -302,13 +333,23 @@ runs: set -euo pipefail helm status "$APP_NAME" -n "$NAMESPACE" || true echo "---" - kubectl get pods -n "$NAMESPACE" -l app.kubernetes.io/instance="$APP_NAME" || true + # Charts in use label pods differently (app.kubernetes.io/instance= on the gateway + # chart, app= on the ingress chart); show whichever selector matches. + SELECTOR="app.kubernetes.io/instance=$APP_NAME" + if [ -z "$(kubectl get pods -n "$NAMESPACE" -l "$SELECTOR" --no-headers 2>/dev/null)" ]; then + SELECTOR="app=$APP_NAME" + fi + echo "Pods (selector: ${SELECTOR}):" + kubectl get pods -n "$NAMESPACE" -l "$SELECTOR" || true - name: Report failure if: failure() shell: bash + env: + APP_NAME: ${{ inputs.app_name }} + NAMESPACE: ${{ inputs.namespace }} run: | - echo "::error title=❌ [HELM] Deploy failed::App '${{ inputs.app_name }}' in namespace '${{ inputs.namespace }}'. Checkpoints — 1) Kubeconfig: ${HELM_GENERIC_KUBECONFIG_STATUS:-⏭️ Not reached} | 2) Helm deploy: ${HELM_GENERIC_DEPLOY_STATUS:-⏭️ Not reached}." + echo "::error title=❌ [HELM] Deploy failed::App '${APP_NAME}' in namespace '${NAMESPACE}'. Checkpoints — 1) Kubeconfig: ${HELM_GENERIC_KUBECONFIG_STATUS:-⏭️ Not reached} | 2) Helm deploy: ${HELM_GENERIC_DEPLOY_STATUS:-⏭️ Not reached}." - name: Clean up kubeconfig if: always() diff --git a/.github/workflows/generic-gateway-helm-template.yml b/.github/workflows/generic-gateway-helm-template.yml index 562c656..1657b66 100644 --- a/.github/workflows/generic-gateway-helm-template.yml +++ b/.github/workflows/generic-gateway-helm-template.yml @@ -717,6 +717,9 @@ jobs: fi KCFG_PATH="$RUNNER_TEMP/kubeconfig" + # Remove the decoded kubeconfig when this step exits so the credential does not linger + # (matters on self-hosted/reused runners). + trap 'rm -f "$KCFG_PATH"' EXIT if echo "$KUBECONFIG_DATA" | grep -q 'apiVersion:'; then printf "%s" "$KUBECONFIG_DATA" > "$KCFG_PATH" else From 7bf16b2299b76e02a6c4abd91f10a81b61748bf6 Mon Sep 17 00:00:00 2001 From: Musa Misto <64855513+MusaMisto@users.noreply.github.com> Date: Sun, 14 Jun 2026 16:14:51 +0300 Subject: [PATCH 5/9] fix(helm-generic): guard empty-array expansion; surface atomic as workflow input F1 - Guard the helm-upgrade array expansions with ${arr[@]+"${arr[@]}"} so an empty EXTRA_SET_ARGS / ATOMIC_ARGS / EXTRA_ARGS_ARR no longer triggers "unbound variable" under `set -u` on bash < 4.4 (a latent regression from the earlier array refactor). Verified on bash 3.2 for both empty and populated arrays; no effect on the two consumers (they always pass non-empty arrays on ubuntu-latest / bash 5.2). F2 - Add an `atomic` input (default 'true') to both reusable workflows and pass it through to the action, so workflow callers can opt out of --atomic auto-rollback. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/actions/helm-generic/action.yml | 6 ++++-- .github/workflows/generic-chart-helm.yml | 6 ++++++ .github/workflows/generic-gateway-helm-template.yml | 6 ++++++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/.github/actions/helm-generic/action.yml b/.github/actions/helm-generic/action.yml index 526649f..13d2a06 100644 --- a/.github/actions/helm-generic/action.yml +++ b/.github/actions/helm-generic/action.yml @@ -312,13 +312,15 @@ runs: fi echo "Executing Helm upgrade with base settings plus extras..." + # The ${arr[@]+"${arr[@]}"} guard expands to nothing when the array is empty, avoiding + # an "unbound variable" error under `set -u` on bash < 4.4 (BASE_SET_ARGS is always set). helm upgrade "${APP_NAME}" "$CHART_REF" \ --install \ --namespace "${NAMESPACE}" \ --create-namespace \ - "${BASE_SET_ARGS[@]}" "${EXTRA_SET_ARGS[@]}" \ + "${BASE_SET_ARGS[@]}" ${EXTRA_SET_ARGS[@]+"${EXTRA_SET_ARGS[@]}"} \ --timeout "${HELM_TIMEOUT}" \ - "${ATOMIC_ARGS[@]}" "${EXTRA_ARGS_ARR[@]}" + ${ATOMIC_ARGS[@]+"${ATOMIC_ARGS[@]}"} ${EXTRA_ARGS_ARR[@]+"${EXTRA_ARGS_ARR[@]}"} echo "HELM_GENERIC_DEPLOY_STATUS=✅ PASSED" >> "$GITHUB_ENV" echo "::notice title=✅ [HELM] Helm deploy complete::App: ${APP_NAME}, namespace: ${NAMESPACE}" echo "::endgroup::" diff --git a/.github/workflows/generic-chart-helm.yml b/.github/workflows/generic-chart-helm.yml index efafb52..568eac7 100644 --- a/.github/workflows/generic-chart-helm.yml +++ b/.github/workflows/generic-chart-helm.yml @@ -85,6 +85,11 @@ on: required: false default: '' type: string + atomic: + description: "If 'true', pass --atomic to helm upgrade so a failed deploy rolls back automatically (implies --wait)." + required: false + default: 'true' + type: string helm-set-values: description: 'Development Helm set values (comma-separated: key1=value1,key2=value2)' required: false @@ -494,6 +499,7 @@ jobs: # Helm timing/flags helm_timeout: ${{ inputs.helm-timeout-minutes != '' && format('{0}m', inputs.helm-timeout-minutes) || '10m' }} extra_args: "--wait" + atomic: ${{ inputs.atomic }} # Translate your previous comma-joined --set into line-based entries extra_set_values: | diff --git a/.github/workflows/generic-gateway-helm-template.yml b/.github/workflows/generic-gateway-helm-template.yml index 1657b66..8de69ae 100644 --- a/.github/workflows/generic-gateway-helm-template.yml +++ b/.github/workflows/generic-gateway-helm-template.yml @@ -115,6 +115,11 @@ on: required: false default: '' type: string + atomic: + description: "If 'true', pass --atomic to helm upgrade so a failed deploy rolls back automatically (implies --wait)." + required: false + default: 'true' + type: string helm-set-values: description: Additional Helm set values line or comma separated required: false @@ -891,6 +896,7 @@ jobs: chart_repo: ${{ inputs.chart-repo }} helm_timeout: ${{ inputs.helm-timeout-minutes != '' && format('{0}m', inputs.helm-timeout-minutes) || '10m' }} extra_args: --wait + atomic: ${{ inputs.atomic }} extra_set_values: | image.repo=${{ env.CONTAINER_REGISTRY }} app.version=${{ needs.version.outputs.version }} From ce072bdde04dee1ac482f59c513d74159b027007 Mon Sep 17 00:00:00 2001 From: Musa Misto <64855513+MusaMisto@users.noreply.github.com> Date: Sun, 14 Jun 2026 16:20:16 +0300 Subject: [PATCH 6/9] fix(helm-generic): force-update helm repo alias; surface add failures N1 - `helm repo add s9generic` previously errored when the alias already existed with a different URL (reused/self-hosted runner); the `|| true` swallowed it and the deploy then pulled from the stale URL. Use --force-update so the alias is repointed at the current chart_repo. Also drop `2>/dev/null || true` so a genuine repo-add failure (e.g. unreachable repo) fails fast with a clear error instead of proceeding to a doomed chart ref, and scope `helm repo update` to the added repo. Verified with helm: same-alias/new-URL now repoints correctly; scoped update works on Helm 4. No effect on ephemeral GitHub-hosted runners. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/actions/helm-generic/action.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/actions/helm-generic/action.yml b/.github/actions/helm-generic/action.yml index 13d2a06..6bb73a5 100644 --- a/.github/actions/helm-generic/action.yml +++ b/.github/actions/helm-generic/action.yml @@ -278,8 +278,10 @@ runs: set -euo pipefail if [ -n "$CHART_REPO" ]; then - helm repo add s9generic "$CHART_REPO" 2>/dev/null || true - helm repo update >/dev/null + # --force-update so a stale 's9generic' alias from a prior run (reused/self-hosted runner) + # is repointed at the current chart_repo instead of silently keeping the old URL. + helm repo add --force-update s9generic "$CHART_REPO" >/dev/null + helm repo update s9generic >/dev/null CHART_REF="s9generic/$CHART" else CHART_REF="$CHART" From 3c51192025d9b1d56b3dae6f2976f103b7d52b71 Mon Sep 17 00:00:00 2001 From: Musa Misto <64855513+MusaMisto@users.noreply.github.com> Date: Sun, 14 Jun 2026 16:28:46 +0300 Subject: [PATCH 7/9] style(sw-cicd): group consecutive GITHUB_ENV writes (shellcheck SC2129) Combine the three CHECKPOINT_*_STATUS echoes in the ci job's announce step into a single `{ ... } >> "$GITHUB_ENV"` block. Behavior identical; clears the only actionlint finding in this workflow. Independent of the helm-generic changes (sw-cicd uses helm-deploy, not helm-generic). Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/sw-cicd.yml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/sw-cicd.yml b/.github/workflows/sw-cicd.yml index c32d6a7..9baeb79 100644 --- a/.github/workflows/sw-cicd.yml +++ b/.github/workflows/sw-cicd.yml @@ -272,9 +272,11 @@ jobs: - name: Announce ci job run: | echo "::notice title=🐳 [DOCKER] CI Build — Job Started::Version: ${{ needs.version.outputs.version }} | Registry: ${{ inputs.container-registry }} | Triggered by: ${{ github.actor }}" - echo "CHECKPOINT_1_STATUS=⏳ Pending" >> "$GITHUB_ENV" - echo "CHECKPOINT_2_STATUS=⏳ Pending" >> "$GITHUB_ENV" - echo "CHECKPOINT_3_STATUS=⏳ Pending" >> "$GITHUB_ENV" + { + echo "CHECKPOINT_1_STATUS=⏳ Pending" + echo "CHECKPOINT_2_STATUS=⏳ Pending" + echo "CHECKPOINT_3_STATUS=⏳ Pending" + } >> "$GITHUB_ENV" - name: Checkout code uses: actions/checkout@v6 From 3492690fd6bc42dc86bfdb13f7d1aca931b378d8 Mon Sep 17 00:00:00 2001 From: Musa Misto <64855513+MusaMisto@users.noreply.github.com> Date: Sun, 14 Jun 2026 16:37:27 +0300 Subject: [PATCH 8/9] kubectl version upgrade --- .github/actions/helm-generic/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/helm-generic/action.yml b/.github/actions/helm-generic/action.yml index 6bb73a5..92164b8 100644 --- a/.github/actions/helm-generic/action.yml +++ b/.github/actions/helm-generic/action.yml @@ -176,7 +176,7 @@ runs: # Pinned for reproducibility (avoids a new kubectl silently changing CI behavior). # Only basic core-resource ops are used here (apply/wait/get/create), so exact skew # with the cluster is not critical; bump this to track your cluster's minor version. - version: 'v1.34.0' + version: 'v1.36.0' - name: Run DB migration Job (init container equivalent) if: ${{ inputs.init_job_image != '' }} From 0026df7510056ff2b55404a3a30e41b98378ff31 Mon Sep 17 00:00:00 2001 From: Musa Misto <64855513+MusaMisto@users.noreply.github.com> Date: Sun, 14 Jun 2026 16:37:47 +0300 Subject: [PATCH 9/9] Kubectl version in between --- .github/actions/helm-generic/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/helm-generic/action.yml b/.github/actions/helm-generic/action.yml index 92164b8..31a70a4 100644 --- a/.github/actions/helm-generic/action.yml +++ b/.github/actions/helm-generic/action.yml @@ -176,7 +176,7 @@ runs: # Pinned for reproducibility (avoids a new kubectl silently changing CI behavior). # Only basic core-resource ops are used here (apply/wait/get/create), so exact skew # with the cluster is not critical; bump this to track your cluster's minor version. - version: 'v1.36.0' + version: 'v1.35.0' - name: Run DB migration Job (init container equivalent) if: ${{ inputs.init_job_image != '' }}