name: render-manifests on: schedule: - cron: '0 15 * * *' workflow_dispatch: push: branches: - main paths: - 'clusters/cl01tl/helm/**' pull_request: branches: - main paths: - 'clusters/cl01tl/helm/**' types: - closed env: CLUSTER: cl01tl BASE_BRANCH: manifests BRANCH_NAME_BASE: auto/update-manifests ASSIGNEE: alexlebens MAIN_DIR: /workspace/alexlebens/infrastructure/infrastructure MANIFEST_DIR: /workspace/alexlebens/infrastructure/infrastructure-manifests jobs: render-manifests: runs-on: ubuntu-js if: >- github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.actor != 'renovate-bot') || (github.event_name == 'pull_request' && github.event.pull_request.merged == true) steps: - name: Checkout Main uses: actions/checkout@v6 with: path: infrastructure fetch-depth: 0 - name: Checkout Manifests uses: actions/checkout@v6 with: ref: manifests path: infrastructure-manifests - name: Set up Helm uses: azure/setup-helm@v4 with: token: ${{ secrets.GITEA_TOKEN }} version: v3.17.2 # Pending https://github.com/helm/helm/pull/30743 cache: true - name: Configure Kubeconfig uses: azure/k8s-set-context@v4 with: method: kubeconfig kubeconfig: ${{ secrets.KUBECONFIG }} - name: Cache Helm Dependencies uses: actions/cache@v5 with: path: | ~/.cache/helm ~/.config/helm key: helm-cache-${{ runner.os }}-${{ hashFiles('infrastructure/clusters/cl01tl/helm/**/Chart.yaml', 'infrastructure/clusters/cl01tl/helm/**/Chart.lock') }} restore-keys: | helm-cache-${{ runner.os }}- - name: Determine Workflow Mode id: mode run: | IS_AUTOMERGE="false" RENDER_ALL="false" DIFF_TARGET="" if [[ "${{ github.event_name }}" == "schedule" || "${{ github.event_name }}" == "workflow_dispatch" ]]; then echo "" echo ">> Mode: Dispatch/Schedule (Render All)" RENDER_ALL="true" elif [[ "${{ github.event_name }}" == "pull_request" ]]; then if [[ "${{ contains(github.event.pull_request.labels.*.name, 'automerge') }}" == "true" ]]; then echo "" echo ">> Mode: PR Merged (Automerge)" IS_AUTOMERGE="true" else echo "" echo ">> Mode: PR Merged (Standard)" fi DIFF_TARGET="HEAD^..HEAD" elif [[ "${{ github.event_name }}" == "push" ]]; then echo "" echo ">> Mode: Push (Standard)" DIFF_TARGET="${{ github.event.before }}..HEAD" fi echo "----" echo "is_automerge=${IS_AUTOMERGE}" >> "$GITHUB_OUTPUT" echo "render_all=${RENDER_ALL}" >> "$GITHUB_OUTPUT" echo "diff_target=${DIFF_TARGET}" >> "$GITHUB_OUTPUT" - name: Prepare Manifest Branch id: prepare-manifest-branch env: IS_AUTOMERGE: ${{ steps.mode.outputs.is_automerge }} run: | cd "${MANIFEST_DIR}" echo "" echo ">> Configure git to use gitea-bot as user ..." git config user.name "gitea-bot" git config user.email "gitea-bot@alexlebens.net" if [[ "$IS_AUTOMERGE" == "true" ]]; then echo "" echo ">> Creating branch ${BRANCH_NAME} ..." BRANCH_NAME="${BRANCH_NAME_BASE}-automerge-$(date +%Y%m%d%H%M%S)" git checkout -b "$BRANCH_NAME" else echo "" echo ">> Checking if PR branch exists ..." BRANCH_NAME="${BRANCH_NAME_BASE}" if git ls-remote --exit-code --heads origin "${BRANCH_NAME}" > /dev/null 2>&1; then echo "" echo ">> Branch '${BRANCH_NAME}' exists, pulling changes ..." git fetch origin "${BRANCH_NAME}" git checkout "${BRANCH_NAME}" git pull --rebase else echo "" echo ">> Branch '${BRANCH_NAME}' does not exist, creating ..." git checkout -b "${BRANCH_NAME}" fi fi echo "----" echo "BRANCH_NAME=${BRANCH_NAME}" >> "$GITHUB_OUTPUT" - name: Check which Directories have Changes id: check-dir-changes env: RENDER_ALL: ${{ steps.mode.outputs.render_all }} DIFF_TARGET: ${{ steps.mode.outputs.diff_target }} run: | cd "${MAIN_DIR}" if [[ "$RENDER_ALL" == "true" ]]; then echo "" echo ">> Triggered on dispatch, will check all paths ..." RENDER_DIR=$(find "clusters/${CLUSTER}/helm" -mindepth 1 -maxdepth 1 -type d -exec basename {} \; | sort -u) else echo "" echo ">> Checking for changes from ${DIFF_TARGET} ..." RENDER_DIR=$(git diff --name-only "${DIFF_TARGET}" | grep -E "^clusters/${CLUSTER}/helm/" | awk -F '/' '{print $4}' | sort -u || true) fi if [ -n "${RENDER_DIR}" ]; then echo "" echo ">> Directories to Render:" echo "${RENDER_DIR}" echo "----" echo "changes-detected=true" >> "$GITHUB_OUTPUT" echo "render-dir-csv=$(echo "${RENDER_DIR}" | paste -sd ',' -)" >> "$GITHUB_OUTPUT" echo "render-dir<> "$GITHUB_OUTPUT" echo "${RENDER_DIR}" >> "$GITHUB_OUTPUT" echo "EOF" >> "$GITHUB_OUTPUT" else echo "" echo ">> No chart changes detected" echo "----" echo "changes-detected=false" >> "$GITHUB_OUTPUT" fi - name: Add Repositories if: steps.check-dir-changes.outputs.changes-detected == 'true' env: RENDER_DIR: ${{ steps.check-dir-changes.outputs.render-dir }} run: | cd "${MAIN_DIR}" echo "" echo ">> Adding repositories for chart dependencies ..." for DIR in ${RENDER_DIR}; do helm dependency list --max-col-width 120 "${MAIN_DIR}/clusters/${CLUSTER}/helm/${DIR}" 2> /dev/null \ | tail -n +2 \ | awk 'NF > 0 { print $1, $3 }' \ | while read -r REPO_NAME REPO_URL; do if [[ "${REPO_URL}" == oci://* ]]; then echo ">> Ignoring OCI repo: ${REPO_URL}" elif [[ -n "${REPO_NAME}" && -n "${REPO_URL}" ]]; then helm repo add "${REPO_NAME}" "${REPO_URL}" fi done || true done if helm repo list > /dev/null 2>&1; then echo "" echo ">> Update repository cache ..." helm repo update fi echo "----" - name: Remove Changed Manifest Files if: steps.check-dir-changes.outputs.changes-detected == 'true' env: RENDER_DIR: ${{ steps.check-dir-changes.outputs.render-dir }} run: | cd "${MANIFEST_DIR}" echo "" echo ">> Remove manifest files and rebuild from source ..." for DIR in ${RENDER_DIR}; do CHART_PATH="${MANIFEST_DIR}/clusters/${CLUSTER}/manifests/${DIR}" echo "" echo "${CHART_PATH}" rm -rf "${CHART_PATH}"/* done echo "----" - name: Render Helm Manifests id: render-manifests if: steps.check-dir-changes.outputs.changes-detected == 'true' env: RENDER_DIR: ${{ steps.check-dir-changes.outputs.render-dir }} run: | cd "${MAIN_DIR}" echo "" echo ">> Rendering Manifests ..." render_chart() { local DIR="$1" local CHART_PATH="${MAIN_DIR}/clusters/${CLUSTER}/helm/${DIR}" local CHART_NAME=$(basename "${CHART_PATH}") echo "" echo ">> Rendering chart: ${CHART_NAME}" if [ -f "${CHART_PATH}/Chart.yaml" ]; then local OUTPUT_FOLDER="${MANIFEST_DIR}/clusters/${CLUSTER}/manifests/${CHART_NAME}/" mkdir -p "${OUTPUT_FOLDER}" cd "${CHART_PATH}" helm dependency update --skip-refresh > /dev/null helm lint --namespace "${CHART_NAME}" --quiet local NAMESPACE="${CHART_NAME}" case "${CHART_NAME}" in "stack") NAMESPACE="argocd" echo "" echo ">> Special Rendering into 'argocd' namespace ..." ;; "cilium" | "coredns" | "metrics-server" | "prometheus-operator-crds") NAMESPACE="kube-system" echo "" echo ">> Special Rendering for ${CHART_NAME} into 'kube-system' namespace ..." ;; *) echo "" echo ">> Standard Rendering for ${CHART_NAME} ..." esac echo "" echo ">> Formating rendered template ..." local TEMPLATE TEMPLATE=$(helm template "${CHART_NAME}" ./ --namespace "${NAMESPACE}" --include-crds --dry-run=server --api-versions "gateway.networking.k8s.io/v1/HTTPRoute") # Format and split rendered template echo "${TEMPLATE}" | yq '... comments=""' | yq 'select(. != null)' | yq -s '"'"${OUTPUT_FOLDER}"'" + .kind + "-" + .metadata.name + ".yaml"' # Strip comments again to ensure formatting correctness if ls "${OUTPUT_FOLDER}"*.yaml 1> /dev/null 2>&1; then yq -i '... comments=""' "${OUTPUT_FOLDER}"*.yaml fi echo "" echo ">> Manifests for ${CHART_NAME} rendered successfully." else echo "" echo ">> Directory ${CHART_PATH} does not contain a Chart.yaml. Skipping ..." fi } export -f render_chart export MAIN_DIR CLUSTER MANIFEST_DIR # Run rendering in parallel for DIR in ${RENDER_DIR}; do echo "${DIR}" done | xargs -n 1 -P 4 -I {} bash -c 'render_chart "$@"' _ {} echo "----" - name: Check for Changes id: check-changes if: steps.check-dir-changes.outputs.changes-detected == 'true' run: | cd "${MANIFEST_DIR}" GIT_CHANGES=$(git status --porcelain) if [ -n "${GIT_CHANGES}" ]; then echo "" echo ">> Changes detected" git status --porcelain echo "----" echo "changes-detected=true" >> "$GITHUB_OUTPUT" else echo "" echo ">> No changes detected, skipping PR creation" echo "----" fi - name: Commit and Push Changes id: commit-push if: steps.check-changes.outputs.changes-detected == 'true' env: BRANCH_NAME: ${{ steps.prepare-manifest-branch.outputs.BRANCH_NAME }} IS_AUTOMERGE: ${{ steps.mode.outputs.is_automerge }} run: | cd "${MANIFEST_DIR}" MSG="chore: Update manifests after change" if [[ "$IS_AUTOMERGE" == "true" ]]; then MSG="chore: Update manifests after automerge" fi echo "" echo ">> Commiting changes to ${BRANCH_NAME} ..." git add . git commit -m "${MSG}" REPO_URL="${{ secrets.REPO_URL }}/${{ gitea.repository }}" echo "" echo ">> Pushing changes to ${REPO_URL} ..." git push -u "https://oauth2:${{ secrets.BOT_TOKEN }}@${REPO_URL#*://}" "${BRANCH_NAME}" echo "----" echo "push=true" >> "$GITHUB_OUTPUT" echo "HEAD_BRANCH=${BRANCH_NAME}" >> "$GITHUB_OUTPUT" - name: Check for Pull Request id: check-for-pull-request if: steps.commit-push.outputs.push == 'true' && steps.mode.outputs.is_automerge == 'false' env: GITEA_TOKEN: ${{ secrets.BOT_TOKEN }} GITEA_URL: ${{ secrets.REPO_URL }} HEAD_BRANCH: ${{ steps.commit-push.outputs.HEAD_BRANCH }} run: | cd "${MANIFEST_DIR}" API_ENDPOINT="${GITEA_URL}/api/v1/repos/${{ gitea.repository }}/pulls?base_branch=${BASE_BRANCH}&state=open&page=1" echo "" echo ">> Checking if PR from branch ${HEAD_BRANCH} into ${BASE_BRANCH}" echo ">> With Endpoint of:" echo "$API_ENDPOINT" HTTP_STATUS=$(curl -X GET -s -w '%{http_code}' -o response_body.json -H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json" "$API_ENDPOINT") if [ "$HTTP_STATUS" == "200" ] && [ "$(cat response_body.json | jq -r .[0].state)" == "open" ]; then echo "" echo ">> Pull Request has been found open, will update" echo "----" echo "pull-request-exists=$(cat response_body.json | jq -r .[0].number)" >> "$GITHUB_OUTPUT" echo "pull-request-url=$(cat response_body.json | jq -r .[0].html_url)" >> "$GITHUB_OUTPUT" else echo "" echo ">> Pull Request not found" echo "----" echo "pull-request-exists=false" >> "$GITHUB_OUTPUT" fi - name: Create Pull Request id: create-pull-request if: steps.commit-push.outputs.push == 'true' && (steps.mode.outputs.is_automerge == 'true' || steps.check-for-pull-request.outputs.pull-request-exists == 'false') env: IS_AUTOMERGE: ${{ steps.mode.outputs.is_automerge }} GITEA_TOKEN: ${{ secrets.BOT_TOKEN }} GITEA_URL: ${{ secrets.REPO_URL }} HEAD_BRANCH: ${{ steps.commit-push.outputs.HEAD_BRANCH }} CHARTS: ${{ steps.check-dir-changes.outputs.render-dir-csv }} EVENT_NAME: ${{ github.event_name }} ACTOR: ${{ github.actor }} SHA: ${{ github.sha }} REF: ${{ github.ref_name }} run: | cd "${MANIFEST_DIR}" API_ENDPOINT="${GITEA_URL}/api/v1/repos/${{ gitea.repository }}/pulls" BODY=$(printf "This PR contains newly rendered Kubernetes manifests automatically generated by the CI workflow.\n\n### Details\n- **Trigger**: \`%s\` by \`@%s\`\n- **Commit**: \`%s\` (on \`%s\`)\n- **Charts Updated**: \`%s\`" "${EVENT_NAME}" "${ACTOR}" "${SHA:0:7}" "${REF}" "${CHARTS}") if [[ "$IS_AUTOMERGE" == "true" ]]; then TITLE="Automated Manifest Update - Automerge" BODY=$(printf "%s\n\n_This PR is expected to be automerged._" "${BODY}") else TITLE="Automated Manifest Update" fi PAYLOAD=$(jq -n --arg head "${HEAD_BRANCH}" --arg base "${BASE_BRANCH}" --arg assignee "${ASSIGNEE}" --arg title "${TITLE}" --arg body "${BODY}" '{head: $head, base: $base, assignee: $assignee, title: $title, body: $body}') HTTP_STATUS=$(curl -X POST -s -w '%{http_code}' -o response_body.json --data "$PAYLOAD" -H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json" "$API_ENDPOINT") if [ "$HTTP_STATUS" == "201" ]; then echo "" echo ">> Pull Request created successfully!" echo "----" echo "pull-request-url=$(jq -r .html_url response_body.json)" >> "$GITHUB_OUTPUT" echo "pull-request-id=$(jq -r .id response_body.json)" >> "$GITHUB_OUTPUT" echo "pull-request-number=$(jq -r .number response_body.json)" >> "$GITHUB_OUTPUT" echo "pull-request-operation=created" >> "$GITHUB_OUTPUT" elif [[ "$HTTP_STATUS" == "422" || "$HTTP_STATUS" == "409" ]]; then echo "" echo ">> Failed to create PR (Already exists)" else echo "" echo ">> Failed to create PR, HTTP status code: $HTTP_STATUS"; exit 1 fi - name: Merge Changes id: merge-changes if: steps.commit-push.outputs.push == 'true' && steps.mode.outputs.is_automerge == 'true' env: GITEA_TOKEN: ${{ secrets.BOT_TOKEN }} GITEA_URL: ${{ secrets.REPO_URL }} PR_NUMBER: ${{ steps.create-pull-request.outputs.pull-request-number }} run: | cd "${MANIFEST_DIR}" API_ENDPOINT="${GITEA_URL}/api/v1/repos/${{ gitea.repository }}/pulls/${PR_NUMBER}/merge" PAYLOAD=$(jq -n --arg Do "merge" '{Do: $Do}') HTTP_STATUS=$(curl -X POST -s -w '%{http_code}' -o response_body.json --data "$PAYLOAD" -H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json" "$API_ENDPOINT") if [ "$HTTP_STATUS" == "200" ]; then echo "" echo ">> Pull Request merged successfully!" echo "----" echo "pull-request-operation=merged" >> "$GITHUB_OUTPUT" else echo "" echo ">> Failed to merge PR, HTTP status code: $HTTP_STATUS"; exit 1 fi - name: Cleanup Branch if: failure() && steps.mode.outputs.is_automerge == 'true' env: BRANCH_NAME: ${{ steps.prepare-manifest-branch.outputs.BRANCH_NAME }} run: | cd "${MANIFEST_DIR}" echo "" echo ">> Removing branch: ${BRANCH_NAME}" git push origin --delete "${BRANCH_NAME}" || true echo "----" - name: ntfy Created uses: niniyas/ntfy-action@master if: steps.create-pull-request.outputs.pull-request-operation == 'created' && steps.mode.outputs.is_automerge == 'false' with: url: "${{ secrets.NTFY_URL }}" topic: "${{ secrets.NTFY_TOPIC }}" title: "Manifest Render - Open PR" priority: 3 headers: '{"Authorization": "Bearer ${{ secrets.NTFY_CRED }}"}' tags: action,successfully,completed details: "Created renderd manifests for cluster '${CLUSTER}' with charts: ${{ steps.check-dir-changes.outputs.render-dir-csv }}" icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/png/gitea.png" actions: '[{"action": "view", "label": "Open Gitea", "url": "${{ steps.create-pull-request.outputs.pull-request-url }}", "clear": true}]' - name: ntfy Updated uses: niniyas/ntfy-action@master if: steps.commit-push.outputs.push == 'true' && steps.check-for-pull-request.outputs.pull-request-exists != 'false' && steps.mode.outputs.is_automerge == 'false' with: url: "${{ secrets.NTFY_URL }}" topic: "${{ secrets.NTFY_TOPIC }}" title: "Manifest Render - PR Updated" priority: 3 headers: '{"Authorization": "Bearer ${{ secrets.NTFY_CRED }}"}' tags: action,successfully,completed details: "Updated rendered manifests PR for cluster '${CLUSTER}' with charts: ${{ steps.check-dir-changes.outputs.render-dir-csv }}" icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/png/gitea.png" actions: '[{"action": "view", "label": "Open Gitea", "url": "${{ steps.check-for-pull-request.outputs.pull-request-url }}", "clear": true}]' - name: ntfy Merged uses: niniyas/ntfy-action@master if: steps.merge-changes.outputs.pull-request-operation == 'merged' with: url: "${{ secrets.NTFY_URL }}" topic: "${{ secrets.NTFY_TOPIC }}" title: "Manifest Render - Automerged" priority: 3 headers: '{"Authorization": "Bearer ${{ secrets.NTFY_CRED }}"}' tags: action,successfully,completed details: "Automerged manifest rendering for cluster '${CLUSTER}' with charts: ${{ steps.check-dir-changes.outputs.render-dir-csv }}" icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/png/gitea.png" actions: '[{"action": "view", "label": "Open Gitea", "url": "${{ steps.create-pull-request.outputs.pull-request-url }}", "clear": true}]' - name: ntfy Failed uses: niniyas/ntfy-action@master if: failure() with: url: "${{ secrets.NTFY_URL }}" topic: "${{ secrets.NTFY_TOPIC }}" title: "Manifest Render Failure" priority: 4 headers: '{"Authorization": "Bearer ${{ secrets.NTFY_CRED }}"}' tags: action,failed details: "Manifest rendering for Infrastructure has failed!" icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/png/gitea.png" actions: '[{"action": "view", "label": "Open Gitea", "url": "https://gitea.alexlebens.dev/alexlebens/infrastructure/actions?workflow=render-manifests.yaml", "clear": true}]' image: true