name: Validate Helm chart on: push: paths: - infra/helm/** - .github/workflows/helm-chart.yml pull_request: paths: - infra/helm/** - .github/workflows/helm-chart.yml permissions: contents: read jobs: validate: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: azure/setup-helm@v4 with: version: v3.17.3 - name: Lint chart run: helm lint infra/helm/medical-chatbot - name: Render defaults and check the immutable-tag guard run: | helm template default infra/helm/medical-chatbot > /tmp/default.yaml # values-production.yaml turns authService/apiGateway on, and their # jwt-secret Secret key is `required` with no default -- real values # only ever exist inline on the live Application, never in Git (see # the comment on secret.jwtSecret in values-production.yaml). This # placeholder exists purely so these renders reach the assertion # they're actually testing (the image-tag guard) instead of failing # on an unrelated missing secret; it is never applied to a cluster. CI_JWT_SECRET=ci-render-only-not-a-real-secret # The live releases carry no image tag in Git -- it is supplied per # deploy as a commit SHA through the ArgoCD Application. Rendering # with an empty tag must FAIL rather than fall back to the chart's # `local` development tag, so assert the failure directly; otherwise # the guard could rot into a silent default unnoticed. if helm template production infra/helm/medical-chatbot --values infra/helm/medical-chatbot/values-production.yaml --set secret.jwtSecret="$CI_JWT_SECRET" --set aiService.image.tag="" --set web.image.tag="" > /tmp/untagged.yaml 2>/tmp/untagged.err; then echo "::error::render succeeded with no image tag; the immutable-tag guard is gone" exit 1 fi grep -q 'image.tag must be set to an immutable tag' /tmp/untagged.err # ...and with a tag it must resolve the GHCR package, not the local # development image name. helm template production infra/helm/medical-chatbot --values infra/helm/medical-chatbot/values-production.yaml --set secret.jwtSecret="$CI_JWT_SECRET" --set aiService.image.repository=ghcr.io/baovu2k4/vsf-duocthu-ai-service --set web.image.repository=ghcr.io/baovu2k4/vsf-duocthu-web --set aiService.image.tag="$GITHUB_SHA" --set web.image.tag="$GITHUB_SHA" > /tmp/tagged.yaml grep -q "image: \"ghcr.io/baovu2k4/vsf-duocthu-ai-service:$GITHUB_SHA\"" /tmp/tagged.yaml grep -q "image: \"ghcr.io/baovu2k4/vsf-duocthu-web:$GITHUB_SHA\"" /tmp/tagged.yaml # These two releases are what realvuxbaro.me actually serves, so their # contract is asserted here rather than trusted by review. - name: Render the live production manifests run: | # See the same placeholder note in the previous step -- real secret # material never enters Git and this is a render-only dry run. helm template medical-chatbot-app infra/helm/medical-chatbot \ --values infra/helm/medical-chatbot/values-production.yaml \ --set secret.jwtSecret=ci-render-only-not-a-real-secret \ > /tmp/prod-app.yaml helm template medical-chatbot-data infra/helm/medical-chatbot \ --values infra/helm/medical-chatbot/values-production-data.yaml \ > /tmp/prod-data.yaml # A bare `grep -q` fails the step with no indication of which # assertion broke, and `set -e` ignores a status inverted with `!`, # so a `! grep -q` assertion can never fail at all. Both directions # go through helpers that name the pattern and exit explicitly. # `--` matters: a YAML list item pattern starts with `-`, which grep # would otherwise parse as an option bundle. expect() { if ! grep -q -- "$2" "$1"; then echo "::error::$1 is missing: $2" exit 1 fi } refute() { if grep -q -- "$2" "$1"; then echo "::error::$1 must not contain: $2" exit 1 fi } # Behavioural parity with the audited production runtime contract. expect /tmp/prod-app.yaml 'ANSWER_MODEL_ID: "qwen.qwen3-next-80b-a3b"' expect /tmp/prod-app.yaml 'ANSWER_PROVIDER: "bedrock-converse"' expect /tmp/prod-app.yaml 'EMBEDDING_PROVIDER: "cohere-v4"' expect /tmp/prod-app.yaml 'EMBEDDING_DIMENSIONS: "1024"' expect /tmp/prod-app.yaml 'EVIDENCE_MINIMUM_SCORE: "0.12"' expect /tmp/prod-app.yaml 'RERANK_ENABLED: "true"' expect /tmp/prod-app.yaml 'AWS_REGION: "us-east-1"' expect /tmp/prod-app.yaml 'checksum/runtime-config:' expect /tmp/prod-app.yaml '- host: "readytochat.realvuxbaro.me"' # The production hostname now lives on this cluster, routed and with # its own certificate secret -- kept separate from the rehearsal # hostname's so one renewal failure cannot take both names offline. expect /tmp/prod-app.yaml '- host: "realvuxbaro.me"' expect /tmp/prod-app.yaml 'secretName: realvuxbaro-tls' expect /tmp/prod-app.yaml 'secretName: readytochat-tls' # Grafana answers on that same public hostname. Anonymous access may # be open, but never as Admin, never with the login form disabled, # and its root URL must be the name users actually arrive on. expect /tmp/prod-app.yaml 'value: "https://realvuxbaro.me/grafana/"' refute /tmp/prod-app.yaml 'value: "Admin"' # grep is line-oriented, so read the value on the line after each # flag rather than trying to match the pair as one pattern. for check in "GF_AUTH_ANONYMOUS_ORG_ROLE:Viewer" "GF_AUTH_DISABLE_LOGIN_FORM:false"; do flag=${check%%:*} want=${check#*:} got=$(grep -A1 -- "$flag" /tmp/prod-app.yaml | grep -- 'value:' | tr -d ' "' | cut -d: -f2) if [ "$got" != "$want" ]; then echo "::error::$flag rendered as '$got', expected '$want'" exit 1 fi done # The app release must own neither data StatefulSet: PostgreSQL and # Qdrant belong to the data release, so an app-side sync failure or # prune can never delete the corpus or the query history. Only those # two use volumeClaimTemplates — the observability PVCs are the app # release's own and are expected here. refute /tmp/prod-app.yaml 'volumeClaimTemplates' expect /tmp/prod-app.yaml 'medical-chatbot-data-medical-chatbot-qdrant' # ...and the data release must own nothing else. refute /tmp/prod-data.yaml 'medical-chatbot-data-medical-chatbot-ai-service' refute /tmp/prod-data.yaml 'kind: Ingress' expect /tmp/prod-data.yaml 'volumeClaimTemplates'