Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
6ac1135
fix: per step authe using ARM_OIDC for terraform
giang-h-vu Jul 13, 2026
c7a1e0a
ci: fail on unknown cluster state and require explicit prod tag
giang-h-vu Jul 14, 2026
4c5378a
ci: extract shared ansible-deploy composite action
giang-h-vu Jul 14, 2026
fc610ff
ci: consolidate PR checks into one path-filtered pipeline
giang-h-vu Jul 14, 2026
141f6b3
fix: remove template injection attack in shell script by moving input…
giang-h-vu Jul 14, 2026
b6caf10
ci: gate image builds on CI and promote releases instead of rebuilding
giang-h-vu Jul 15, 2026
415f338
chore: add lint, format and typecheck tool configs for all stacks
giang-h-vu Jul 15, 2026
3b8afdf
chore: apply spotless to java services
giang-h-vu Jul 15, 2026
7d01cee
chore: apply ruff format to python services
giang-h-vu Jul 15, 2026
f9b8a85
fix: satisfy stricter shared ruff ruleset in email service
giang-h-vu Jul 15, 2026
471f1e6
fix: replace wildcard import and exempt test names from MethodName
giang-h-vu Jul 15, 2026
12bbe4b
chore: apply prettier to web-client
giang-h-vu Jul 15, 2026
c75877e
chore: record reformat commits in .git-blame-ignore-revs
giang-h-vu Jul 15, 2026
6258c9e
fix: resolve mypy findings in genai and email services
giang-h-vu Jul 15, 2026
ba9d456
chore: extend pre-commit with fast auto-fix hooks
giang-h-vu Jul 15, 2026
5798714
ci: gate lint, format check and typecheck in the PR pipeline
giang-h-vu Jul 15, 2026
d3531f6
chore: apply pre-commit hygiene fixes repo-wide
giang-h-vu Jul 15, 2026
be4c38f
chore: exclude helm templates from check-yaml hook
giang-h-vu Jul 15, 2026
dca7d74
add preinstall in prepare for auto setup
giang-h-vu Jul 16, 2026
3b37350
chore: improve codes security
giang-h-vu Jul 16, 2026
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
2 changes: 1 addition & 1 deletion .claude/skills/deep-teaching/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,4 +163,4 @@ If time runs short or the learner needs to stop, explicitly note what remains un

> "Before I walk you through this — what's your current read on what's happening here? Even a rough take is useful. I want to know what mental model we're starting from before I fill in any gaps."

This opening does three things: it respects the learner's existing knowledge, it surfaces misconceptions early, and it makes them an active participant from the first moment.
This opening does three things: it respects the learner's existing knowledge, it surfaces misconceptions early, and it makes them an active participant from the first moment.
1 change: 0 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,3 @@ LANGFUSE_HOST=http://langfuse-web:3000
# project with exactly these keys on first boot. Keep public/secret consistent.
LANGFUSE_PUBLIC_KEY=pk-lf-4...
LANGFUSE_SECRET_KEY=sk-lf-8...

10 changes: 10 additions & 0 deletions .git-blame-ignore-revs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Purely mechanical reformat commits — skip in git blame.
# Enable locally once: git config blame.ignoreRevsFile .git-blame-ignore-revs
# (GitHub's blame view picks this file up automatically.)

# chore: apply spotless to java services
3b8afdf032ce10f07efefbcccd42ba6390a9baef
# chore: apply ruff format to python services
7d01cee37613d6ac683ab3a161c55c3ae8b14eed
# chore: apply prettier to web-client
12bbe4b6506cd69a78188018b1eaf09c79e3cd64
133 changes: 133 additions & 0 deletions .github/actions/ansible-deploy/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
name: Ansible deploy
description: >-
Installs the Ansible toolchain, runs the platform bootstrap and app deploy
playbooks against the target inventory, smoke-checks the public URL, and
writes a deploy summary. The caller must provide a kubeconfig via the
KUBECONFIG env var on the `uses:` step (the kubernetes.core modules read it
through the inventory's `kubeconfig` var).

inputs:
inventory:
description: "Inventory path relative to infra/ansible (e.g. inventories/dev/hosts.yml)"
required: true
image_tag:
description: "Final, ready-to-use image tag (callers normalize sha-/semver forms)"
required: true
vault_password:
description: "Ansible vault password for the target environment"
required: true
bootstrap:
description: "Set to 'true' to run bootstrap.yml (platform add-ons) before the deploy — dev/AKS only"
required: false
default: "false"
ingress_ip:
description: "Reserved static IP for ingress-nginx (required when bootstrap=true)"
required: false
default: ""
azure_lb_resource_group:
description: "Resource group holding the reserved IP (dev/AKS only)"
required: false
default: ""
app_url:
description: "Public URL to smoke-check after deploy (strict TLS; skipped when empty)"
required: false
default: ""
extra_args:
description: "Extra ansible-playbook args for deploy.yml (e.g. -e ingress_host=...)"
required: false
default: ""

runs:
using: composite
steps:
- uses: actions/setup-python@v6
with:
python-version: '3.12'

# Pinned so a deploy never breaks because pip resolved a different
# ansible-core that morning — bump deliberately, with a diff.
- name: Install Ansible + collections
shell: bash
run: |
pip install 'ansible-core==2.17.*' kubernetes
ansible-galaxy collection install -r infra/ansible/requirements.yml

- uses: azure/setup-helm@v4
with:
version: v3.16.0

# Only dev/AKS starts from a bare cluster; prod/TUM provides ingress-nginx
# + cert-manager, so there the step is skipped outright (grey in the step
# list). The caller opts in explicitly via the bootstrap input; the
# inventory's install_platform flag remains the inner guard in the playbook.
- name: Bootstrap platform (dev/AKS only)
if: inputs.bootstrap == 'true'
env:
vault_password: ${{ inputs.vault_password }}
inventory: ${{ inputs.inventory }}
ingress_ip: ${{ inputs.ingress_ip }}
azure_lb_resource_group: ${{ inputs.azure_lb_resource_group }}
shell: bash
working-directory: infra/ansible
run: |
trap 'rm -f vault-pass' EXIT
echo "$vault_password" > vault-pass
ansible-playbook -i "$inventory" playbooks/bootstrap.yml \
--vault-password-file vault-pass \
-e "ingress_ip=${ingress_ip}" \
-e "azure_lb_resource_group=${azure_lb_resource_group}"

- name: Deploy app
shell: bash
env:
vault_password: ${{ inputs.vault_password }}
inventory: ${{ inputs.inventory }}
image_tag: ${{ inputs.image_tag }}
extra_args: ${{ inputs.extra_args }}
working-directory: infra/ansible
run: |
trap 'rm -f vault-pass' EXIT
echo "${vault_password}" > vault-pass
ansible-playbook -i "${inventory}" playbooks/deploy.yml \
--vault-password-file vault-pass \
-e "image_tag=${image_tag}" ${extra_args}

# Outside-in check: helm --wait (inside deploy.yml) already proved the pods
# are Ready; this proves the route to them — LB, ingress, TLS, DNS. No -k:
# an untrusted (staging-issuer) certificate must fail the deploy.
- name: Smoke check
if: inputs.app_url != ''
env:
app_url: ${{ inputs.app_url }}
shell: bash
run: |
echo "Probing ${app_url} ..."
for i in $(seq 1 12); do
if curl -sfo /dev/null --max-time 10 "${app_url}"; then
echo "OK (attempt $i)"
exit 0
fi
sleep 10
done
echo "::error::${app_url} did not answer within 2 minutes after deploy"
exit 1

- name: Write deploy summary
shell: bash
env:
inventory: ${{ inputs.inventory }}
image_tag: ${{ inputs.image_tag }}
app_url: ${{ inputs.app_url }}
run: |
{
echo "## Deployed ✅"
echo ""
echo "| | |"
echo "|---|---|"
echo "| Inventory | \`${inventory}\` |"
echo "| Image tag | \`${image_tag}\` |"
if [ -n "${app_url}" ]; then
echo "| URL | ${app_url} |"
fi
echo ""
} >> "$GITHUB_STEP_SUMMARY"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
48 changes: 35 additions & 13 deletions .github/workflows/build-images.yml
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
name: Build & Push Images

# Publishes container images to GHCR on merge to main and on version tags.
# Pull-request CI (build + test) stays in the per-service ci-*.yml workflows.
# Middle link of the deploy chain: CI (tests green on main) → this workflow
# (build & push) → CD - Dev (workflow_run on this one). Merges to main only
# reach here after the CI workflow SUCCEEDS — never build images from a red
# main. Release semver images (1.2.3) are NOT built here: cd-prod.yml
# PROMOTES them by retagging the sha- image this workflow already published
# (build once, deploy many). Deliberately no tag/PR triggers — a git tag can
# point at any commit, so a tag-triggered build would bypass the CI gate.
# Note: workflow_run is an OR-list, not a join — never add a second workflow
# here expecting "wait for both".
on:
push:
workflow_run:
workflows: ["CI"]
types: [completed]
branches: [main]
tags: ['v*']
pull_request:
branches:
- main
workflow_dispatch:

env:
Expand All @@ -23,6 +28,9 @@ permissions:
jobs:
build:
name: ${{ matrix.image }}
# workflow_run fires on ANY completion (failure/cancelled included) —
# only build when the CI run actually succeeded.
if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
Expand All @@ -42,9 +50,14 @@ jobs:
image: jobready-genai

steps:
# On workflow_run, github.sha is main's tip at event time — NOT
# necessarily the commit CI just validated (two quick merges can race).
# Pin the checkout to the exact commit the triggering CI run tested.
- uses: actions/checkout@v7
with:
ref: ${{ github.event.workflow_run.head_sha || github.sha }}

# The default docker driver can't export the git GitHub Actions cache;
# The default docker driver can't export the git GitHub Actions cache;
# the container driver from Buildx can.
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
Expand All @@ -56,18 +69,27 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

# Same race as the checkout: metadata-action's `type=sha` reads
# github.sha, so derive the short SHA from the commit we actually
# checked out (the CI run's head_sha) instead.
- name: Compute short SHA
id: sha
env:
SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
run: echo "short=${SHA:0:7}" >> "$GITHUB_OUTPUT"

- name: Compute tags & labels
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE_PREFIX }}/${{ matrix.image }}
# SHA tag (sha-<short>) for precise CD pins, latest on default branch,
# semver tags on v* releases.
# SHA tag (sha-<short>) for precise CD pins — cd-dev deploys exactly
# this tag, and cd-prod's promote job retags it with the release
# semver. `latest` on default branch (workflow_run sets github.ref
# to the default branch, so CI-chained builds from main get it).
tags: |
type=sha
type=raw,value=sha-${{ steps.sha.outputs.short }}
type=raw,value=latest,enable={{is_default_branch}}
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}

- name: Build & push
uses: docker/build-push-action@v6
Expand Down
95 changes: 53 additions & 42 deletions .github/workflows/cd-dev.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
name: CD - Dev (AKS)

# Runs after images are published to GHCR on main — i.e. when a PR is merged.
# PR builds still publish images but do NOT deploy.
# Last link of the deploy chain: CI (tests green on main) → Build & Push
# Images (workflow_run on CI) → this workflow. Fires only after images for
# the merged commit are actually in GHCR.
on:
workflow_run:
workflows: ["Build & Push Images"]
Expand Down Expand Up @@ -31,6 +32,16 @@ jobs:
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest
environment: dev
# Terraform (backend + provider) authenticates on its own via GitHub OIDC,
# minting a fresh token per auth. It must NOT ride on the az CLI login:
# that assertion expires 5 min after azure/login, and the wake step can run
# 10+ min when the cluster is stopped (AADSTS700024). az CLI steps are fine
# only right after azure/login — re-login before adding one later in the job.
env:
ARM_USE_OIDC: "true"
ARM_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
ARM_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
ARM_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
steps:
- uses: actions/checkout@v7

Expand All @@ -46,22 +57,26 @@ jobs:
# any deploy must wake the cluster first. The script auto-discovers the
# cluster by its Terraform tags, so no RG/name secrets are needed.
# Absent is fine: Terraform creates it on first run.
# Allowlist the exact strings cluster.sh emits (powerState.code is only
# ever Running/Stopped; "does not exist"/"absent" mean no cluster yet)
# and fail loudly on anything else — a clear error here beats a
# confusing Terraform failure two steps later.
- name: Wake cluster if stopped
env:
SUBSCRIPTION: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
run: |
if ./scripts/cluster.sh status | grep -q "Stopped"; then
./scripts/cluster.sh start
else
echo "Cluster running or not created yet — nothing to wake."
fi
STATE=$(./scripts/cluster.sh status)
echo "$STATE"
case "$STATE" in
*Running*|*"does not exist"*|*absent*) echo "Nothing to wake." ;;
*Stopped*) ./scripts/cluster.sh start ;;
*) echo "::error::Unexpected cluster.sh output — not proceeding: $STATE"; exit 1 ;;
esac

- uses: hashicorp/setup-terraform@v3

- name: Terraform apply
working-directory: infra/terraform
env:
ARM_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
run: |
terraform init \
-backend-config="resource_group_name=${{ secrets.TFSTATE_RG }}" \
Expand All @@ -74,42 +89,38 @@ jobs:
id: tf
working-directory: infra/terraform
run: |
echo "ingress_ip=$(terraform output -raw ingress_ip)" >> "$GITHUB_OUTPUT"
echo "fqdn=$(terraform output -raw ingress_fqdn)" >> "$GITHUB_OUTPUT"
echo "rg=$(terraform output -raw resource_group_name)" >> "$GITHUB_OUTPUT"
{
echo "ingress_ip=$(terraform output -raw ingress_ip)"
echo "fqdn=$(terraform output -raw ingress_fqdn)"
echo "rg=$(terraform output -raw resource_group_name)"
} >> "$GITHUB_OUTPUT"
terraform output -raw kube_config > "$RUNNER_TEMP/kubeconfig"

- uses: actions/setup-python@v6
with:
python-version: '3.12'

- name: Install Ansible + collections
run: |
pip install ansible-core kubernetes
ansible-galaxy collection install -r infra/ansible/requirements.yml

- name: Set up Helm
uses: azure/setup-helm@v4
with:
version: v3.16.0

- name: Bootstrap + deploy
working-directory: infra/ansible
# Trigger interpretation lives here, not in the action: a workflow_run
# deploy pins the exact sha- tag that build just published; a manual
# dispatch uses the given input.
- name: Compute image tag
env:
KUBECONFIG: ${{ runner.temp }}/kubeconfig
head_sha: ${{ github.event.workflow_run.head_sha }}
image_tag: ${{ inputs.image_tag }}
id: tag
run: |
trap 'rm -f vault-pass' EXIT
echo "${{ secrets.ANSIBLE_VAULT_PASSWORD_DEV }}" > vault-pass
if [ -n "${{ github.event.workflow_run.head_sha }}" ]; then
IMAGE_TAG="sha-$(echo ${{ github.event.workflow_run.head_sha }} | cut -c1-7)"
if [ -n "${head_sha}" ]; then
echo "tag=sha-$(echo "${head_sha}" | cut -c1-7)" >> "$GITHUB_OUTPUT"
else
IMAGE_TAG="${{ inputs.image_tag }}"
echo "tag=${image_tag}" >> "$GITHUB_OUTPUT"
fi
ansible-playbook -i inventories/dev/hosts.yml playbooks/bootstrap.yml \
--vault-password-file vault-pass \
-e ingress_ip=${{ steps.tf.outputs.ingress_ip }} \
-e azure_lb_resource_group=${{ steps.tf.outputs.rg }}
ansible-playbook -i inventories/dev/hosts.yml playbooks/deploy.yml \
--vault-password-file vault-pass \
-e image_tag=${IMAGE_TAG} \
-e ingress_host=${{ steps.tf.outputs.fqdn }}

- name: Deploy via Ansible
uses: ./.github/actions/ansible-deploy
env:
KUBECONFIG: ${{ runner.temp }}/kubeconfig
with:
inventory: inventories/dev/hosts.yml
image_tag: ${{ steps.tag.outputs.tag }}
vault_password: ${{ secrets.ANSIBLE_VAULT_PASSWORD_DEV }}
bootstrap: "true"
ingress_ip: ${{ steps.tf.outputs.ingress_ip }}
azure_lb_resource_group: ${{ steps.tf.outputs.rg }}
app_url: https://${{ steps.tf.outputs.fqdn }}
extra_args: -e ingress_host=${{ steps.tf.outputs.fqdn }}
Loading
Loading