Update deploy.yaml #38
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # deploy.yaml | ||
| # Copyright (c) 2025 Affinity7 Consulting Ltd | ||
| # Version: v2 (reusable, minimal auth precedence) | ||
| # SPDX-License-Identifier: MIT | ||
| on: | ||
| workflow_call: | ||
| inputs: | ||
| github_runner: | ||
| required: true | ||
| type: string | ||
| namespace: | ||
| required: true | ||
| type: string | ||
| target_environment: | ||
| description: > | ||
| The environment to deploy to (e.g., `dev`, `qa`, `prod`). | ||
| - If the environment maps to a **single cluster** in `env_map`, this is all you need. | ||
| - If the environment maps to **multiple clusters**, you must also set `target_cluster`. | ||
| required: true | ||
| default: "dev" | ||
| type: string | ||
| # REQUIRED when env has >1 clusters | ||
| target_cluster: | ||
| description: > | ||
| The specific cluster to deploy to when the selected target_environment | ||
| has more than one cluster in `env_map`. | ||
| - For single-cluster environments (like `dev`, `qa`), leave this empty. | ||
| - For multi-cluster environments (like `prod` with both `aks-prod-weu` and `aks-prod-use`), | ||
| you must provide one of the valid cluster names from `env_map`. | ||
| Example: `aks-prod-weu` | ||
| required: false | ||
| default: "" | ||
| type: string | ||
| ref: | ||
| description: "The github ref to use for checking out files" | ||
| required: false | ||
| type: string | ||
| default: ${{ github.ref || github.sha }} | ||
| delete_first: | ||
| description: "Delete the namespaced app first before deploying it." | ||
| required: false | ||
| type: boolean | ||
| delete_only: | ||
| description: "Delete ArgoCD app(s) without redeploying" | ||
| required: false | ||
| type: boolean | ||
| default: false | ||
| cd_repo: | ||
| required: true | ||
| type: string | ||
| cd_repo_org: | ||
| required: true | ||
| type: string | ||
| github_environment: | ||
| required: false | ||
| type: string | ||
| # Mode A (single app) | ||
| application: | ||
| required: false | ||
| type: string | ||
| deploy_path: | ||
| description: "The repo path to the deployment files" | ||
| required: false | ||
| type: string | ||
| default: Deployments | ||
| image_tag: | ||
| required: false | ||
| type: string | ||
| image_base_name: | ||
| required: false | ||
| type: string | ||
| image_base_names: | ||
| required: false | ||
| type: string | ||
| overlay_dir: | ||
| required: true | ||
| type: string | ||
| default: "" | ||
| # Mode B (multi app) | ||
| application_details: | ||
| description: >- | ||
| JSON array where each item maps: | ||
| { "name" => application, "images" => image_base_names[], "path" => deploy_path } | ||
| Example: | ||
| [ | ||
| {"name":"app1","images":["repo/app1","repo/sidecar"],"path":"services/app1/overlays/prod"}, | ||
| {"name":"app2","images":["repo/app2"],"path":"apps/app2/overlays/prod"} | ||
| ] | ||
| required: false | ||
| type: string | ||
| default: '' | ||
| # Environment map (single supported shape) | ||
| env_map: | ||
| description: >- | ||
| ONLY this JSON shape is supported: | ||
| { | ||
| "<env>": { | ||
| "cluster_count": N, | ||
| "clusters": [ | ||
| { "cluster": "...", "dns_zone": "...", "container_registry": "...", "uami_map": [...] } | ||
| ] | ||
| } | ||
| } | ||
| required: false | ||
| type: string | ||
| # Argo / misc | ||
| argocd_auth_token: | ||
| required: false | ||
| type: string | ||
| argocd_username: | ||
| required: false | ||
| type: string | ||
| argocd_password: | ||
| required: false | ||
| type: string | ||
| kustomize_version: | ||
| required: false | ||
| type: string | ||
| default: "5.0.1" | ||
| skip_status_check: | ||
| required: false | ||
| default: false | ||
| type: boolean | ||
| insecure_argo: | ||
| required: false | ||
| default: false | ||
| type: boolean | ||
| debug: | ||
| required: false | ||
| type: boolean | ||
| default: false | ||
| secrets: | ||
| CONTINUOUS_DEPLOYMENT_GH_APP_ID: | ||
| required: true | ||
| CONTINUOUS_DEPLOYMENT_GH_APP_PRIVATE_KEY: | ||
| required: true | ||
| ARGOCD_CA_CERT: | ||
| required: false | ||
| ARGOCD_USERNAME: | ||
| required: false | ||
| ARGOCD_PASSWORD: | ||
| required: false | ||
| jobs: | ||
| deploy: | ||
| name: >- | ||
| ${{ inputs.target_environment }}${{ inputs.target_cluster != '' && format(' - {0}', inputs.target_cluster) || '' }} | ||
| runs-on: ${{ inputs.github_runner }} | ||
| environment: ${{ inputs.github_environment }} | ||
| outputs: | ||
| cd_path: ${{ steps.cdroot.outputs.cd_root }} | ||
| steps: | ||
| - name: Checkout repo | ||
| if: ${{ inputs.delete_only == false }} | ||
| uses: actions/checkout@v4 | ||
| with: | ||
| fetch-depth: 1 | ||
| repository: ${{ github.repository }} | ||
| path: source | ||
| ref: ${{ inputs.ref }} | ||
| - name: Report usage metrics (non-blocking) | ||
| uses: actions/github-script@v7 | ||
| continue-on-error: true | ||
| timeout-minutes: 0.5 | ||
| env: | ||
| USAGE_ENDPOINT: https://gitopsmanager.io/github-action-metrics | ||
| with: | ||
| script: | | ||
| const fetch = global.fetch; | ||
| const endpoint = process.env.USAGE_ENDPOINT; | ||
| if (!endpoint) { | ||
| core.warning('❌ USAGE_ENDPOINT is not defined'); | ||
| return; | ||
| } | ||
| const repoFull = process.env.GITHUB_REPOSITORY || ""; | ||
| const [org, repo] = repoFull.split("/"); | ||
| const payload = { | ||
| org, | ||
| repo, | ||
| action: "deploy", // or "build" etc. | ||
| timestamp: new Date().toISOString(), | ||
| }; | ||
| core.info(`📊 Sending metrics: ${JSON.stringify(payload)}`); | ||
| try { | ||
| const resp = await fetch(endpoint, { | ||
| method: "POST", | ||
| headers: { "Content-Type": "application/json" }, | ||
| body: JSON.stringify(payload), | ||
| }); | ||
| const text = await resp.text(); // read raw body once | ||
| if (resp.ok) { | ||
| core.info(`✅ Usage metrics reported successfully (${resp.status}).`); | ||
| core.info(`🔍 Response body: ${text}`); | ||
| } else { | ||
| core.warning(`⚠️ Metrics report failed: HTTP ${resp.status}`); | ||
| core.warning(`Response body: ${text}`); | ||
| } | ||
| } catch (err) { | ||
| core.warning(`❌ Metrics request threw: ${err.message}`); | ||
| } | ||
| - name: Generate GitHub App token | ||
| id: generate_token | ||
| uses: actions/create-github-app-token@v2 | ||
| with: | ||
| app-id: ${{ secrets.CONTINUOUS_DEPLOYMENT_GH_APP_ID }} | ||
| private-key: ${{ secrets.CONTINUOUS_DEPLOYMENT_GH_APP_PRIVATE_KEY }} | ||
| owner: ${{ inputs.cd_repo_org }} | ||
| repositories: ${{ inputs.cd_repo }} | ||
| - name: Checkout reusable workflow repo | ||
| if: ${{ inputs.delete_only == false }} | ||
| uses: actions/checkout@v4 | ||
| with: | ||
| repository: gitopsmanager/k8s-deploy | ||
| ref: v2 | ||
| path: reusable | ||
| - name: Checkout continuous-deployment repo | ||
| uses: actions/checkout@v4 | ||
| with: | ||
| repository: ${{ inputs.cd_repo_org }}/${{ inputs.cd_repo }} | ||
| token: ${{ steps.generate_token.outputs.token }} | ||
| path: continuous-deployment | ||
| - name: Detect cloud | ||
| id: cloud | ||
| if: ${{ inputs.delete_only == false }} | ||
| uses: gitopsmanager/detect-cloud@v1 | ||
| with: | ||
| timeout-ms: 800 | ||
| - name: Warn if GitHub-hosted (unknown cloud) | ||
| if: ${{ steps.cloud.outputs.provider == 'unknown' && inputs.delete_only == false }} | ||
| run: | | ||
| echo "⚠️ Running on a GitHub-hosted runner." | ||
| echo "Workload Identity mappings for AWS/Azure self-hosted runners will not apply." | ||
| - name: Import ENV_MAP from runner | ||
| shell: bash | ||
| run: | | ||
| printf "ENV_MAP<<EOF\n%s\nEOF\n" "$ENV_MAP" >> $GITHUB_ENV | ||
| # Strict env_map parsing + cluster selection (with ENV_MAP fallback) | ||
| - name: Load environment config (JSON) | ||
| id: env | ||
| uses: actions/github-script@v7 | ||
| env: | ||
| INLINE_ENV_MAP: ${{ inputs.env_map }} # workflow input (preferred) | ||
| ENV_MAP: ${{ env.ENV_MAP }} # fallback env var (e.g., injected from ConfigMap on self-hosted) | ||
| ENVIRONMENT: ${{ inputs.target_environment }} | ||
| CLUSTER_IN: ${{ inputs.target_cluster }} | ||
| NAMESPACE: ${{ inputs.namespace }} | ||
| with: | ||
| script: | | ||
| const sourceInput = (process.env.INLINE_ENV_MAP || '').trim(); | ||
| const sourceEnv = (process.env.ENV_MAP || '').trim(); | ||
| const raw = sourceInput || sourceEnv; | ||
| if (!raw) { | ||
| core.setFailed('❌ No env_map provided. Pass inputs.env_map OR set ENV_MAP environment variable.'); | ||
| return; | ||
| } | ||
| core.info(`Using env_map from ${sourceInput ? 'workflow input (inputs.env_map)' : 'ENV_MAP environment variable'}.`); | ||
| let map; | ||
| try { map = JSON.parse(raw); } | ||
| catch (err) { core.setFailed(`❌ env_map is not valid JSON: ${err.message}`); return; } | ||
| const envName = (process.env.ENVIRONMENT || '').trim(); | ||
| const clusterIn = (process.env.CLUSTER_IN || '').trim(); | ||
| let selected; | ||
| if (clusterIn) { | ||
| // Cluster override: search ALL clusters across ALL environments | ||
| const allClusters = Object.entries(map) | ||
| .flatMap(([env, val]) => (val.clusters || []).map(c => ({ ...c, __env: env }))); | ||
| selected = allClusters.find(c => String(c.cluster).toLowerCase() === clusterIn.toLowerCase()); | ||
| if (!selected) { | ||
| const available = allClusters.map(c => `${c.cluster} (env=${c.__env})`); | ||
| core.setFailed(`❌ target_cluster '${clusterIn}' not found. Available: ${available.join(', ')}`); | ||
| return; | ||
| } | ||
| if (selected.__env !== envName) { | ||
| core.warning(`⚠️ target_cluster '${clusterIn}' belongs to env '${selected.__env}', not requested env '${envName}'. Proceeding anyway.`); | ||
| } | ||
| core.info(`Cluster override: using cluster '${selected.cluster}' from env '${selected.__env}'`); | ||
| } else { | ||
| // Normal environment-based selection | ||
| if (!Object.prototype.hasOwnProperty.call(map, envName)) { | ||
| core.setFailed(`❌ Environment '${envName}' not found. Available: ${Object.keys(map).join(', ')}`); | ||
| return; | ||
| } | ||
| const entry = map[envName]; | ||
| if (!entry || typeof entry !== 'object' || !Array.isArray(entry.clusters)) { | ||
| core.setFailed(`❌ env_map['${envName}'] must be { cluster_count, clusters: [...] }`); | ||
| return; | ||
| } | ||
| if (entry.clusters.length === 0) { | ||
| core.setFailed(`❌ env_map['${envName}'].clusters is empty`); | ||
| return; | ||
| } | ||
| if (entry.clusters.length > 1) { | ||
| const names = entry.clusters.map(c => c.cluster).filter(Boolean); | ||
| core.setFailed(`❌ '${envName}' has multiple clusters. Provide target_cluster. Options: ${names.join(', ')}`); | ||
| return; | ||
| } | ||
| selected = entry.clusters[0]; | ||
| core.info(`Selected single cluster '${selected.cluster}' from env '${envName}'`); | ||
| } | ||
| // Final validation + outputs | ||
| const cluster = String(selected.cluster || ''); | ||
| const dnsZone = String(selected.dns_zone || ''); | ||
| const registry = String(selected.container_registry || ''); | ||
| const uami = Array.isArray(selected.uami_map) ? selected.uami_map : []; | ||
| if (!cluster) { core.setFailed('❌ Selected cluster name is empty'); return; } | ||
| if (!dnsZone) { core.setFailed(`❌ Cluster '${cluster}' is missing dns_zone`); return; } | ||
| core.setOutput('cluster', cluster); | ||
| core.setOutput('dns_zone', dnsZone); | ||
| core.setOutput('container_registry', registry); | ||
| core.setOutput('uami_map', JSON.stringify(uami)); | ||
| core.setOutput('namespace', process.env.NAMESPACE); | ||
| - name: Export CD_ROOT | ||
| id: cdroot | ||
| run: | | ||
| CD_ROOT="continuous-deployment/${{ steps.env.outputs.cluster }}/${{ inputs.namespace }}" | ||
| echo "CD_ROOT=$CD_ROOT" >> "$GITHUB_ENV" | ||
| echo "cd_root=$CD_ROOT" >> "$GITHUB_OUTPUT" | ||
| - name: Export UAMI env vars (uami_name => client_id) | ||
| id: uami | ||
| if: ${{ inputs.delete_only == false }} | ||
| uses: actions/github-script@v7 | ||
| env: | ||
| UAMI_JSON: ${{ steps.env.outputs.uami_map }} | ||
| CLUSTER: ${{ steps.env.outputs.cluster }} | ||
| with: | ||
| script: | | ||
| const clusterName = (process.env.CLUSTER || '').trim(); | ||
| let arr = []; | ||
| try { arr = JSON.parse(process.env.UAMI_JSON || '[]'); } | ||
| catch { core.setFailed('❌ Selected cluster uami_map is not valid JSON'); return; } | ||
| if (!Array.isArray(arr) || arr.length === 0) { | ||
| core.info('No UAMI entries for selected cluster.'); | ||
| core.setOutput("uami_vars", "{}"); | ||
| return; | ||
| } | ||
| const seen = new Set(); | ||
| const exported = {}; | ||
| for (const [i, u] of arr.entries()) { | ||
| let name = String(u.uami_name || '').trim(); | ||
| const rg = String(u.uami_resource_group || '').trim(); | ||
| const cid = String(u.client_id || '').trim(); | ||
| if (!name || !cid) { | ||
| core.warning(`Skipping UAMI index ${i}: missing uami_name or client_id.`); | ||
| continue; | ||
| } | ||
| // Remove "<clusterName>-" prefix if present | ||
| const prefix = clusterName + "-"; | ||
| if (name.toLowerCase().startsWith(prefix.toLowerCase())) { | ||
| name = name.substring(prefix.length); | ||
| } | ||
| // Replace '-' with '_' | ||
| let varName = name.replace(/-/g, "_"); | ||
| // Ensure valid shell identifier | ||
| if (!/^[A-Za-z_]/.test(varName)) varName = `_${varName}`; | ||
| if (seen.has(varName)) { | ||
| core.warning(`Duplicate UAMI var '${varName}' (rg='${rg}'). Skipping duplicate.`); | ||
| continue; | ||
| } | ||
| exported[varName.toLowerCase()] = cid; | ||
| seen.add(varName); | ||
| } | ||
| core.info("UAMI vars exported:"); | ||
| core.info(JSON.stringify(exported, null, 2)); | ||
| // Set JSON object as step output | ||
| core.setOutput("uami_vars", JSON.stringify(exported)); | ||
| # Unify to a single apps[] list for either mode | ||
| - name: Resolve apps (single or multi) | ||
| id: apps | ||
| uses: actions/github-script@v7 | ||
| env: | ||
| APP_DETAILS: ${{ inputs.application_details }} | ||
| APPLICATION: ${{ inputs.application }} | ||
| DEPLOY_PATH: ${{ inputs.deploy_path }} | ||
| IMG_ONE: ${{ inputs.image_base_name }} | ||
| IMG_LIST: ${{ inputs.image_base_names }} | ||
| with: | ||
| script: | | ||
| const detailsRaw = (process.env.APP_DETAILS || '').trim(); | ||
| let apps = []; | ||
| if (detailsRaw) { | ||
| let arr; | ||
| try { arr = JSON.parse(detailsRaw); } | ||
| catch (e) { core.setFailed(`❌ application_details is not valid JSON: ${e.message}`); return; } | ||
| // Normalize to array if single object | ||
| if (!Array.isArray(arr)) { | ||
| core.info('application_details is a single object → wrapping in an array'); | ||
| arr = [arr]; | ||
| } | ||
| for (let i = 0; i < arr.length; i++) { | ||
| const it = arr[i] || {}; | ||
| const name = String(it.name || '').trim(); | ||
| const path = String(it.path || '').trim(); | ||
| const images = Array.isArray(it.images) ? it.images.map(String) : []; | ||
| if (!name) { core.setFailed(`❌ application_details[${i}].name is required`); return; } | ||
| if (!path) { core.setFailed(`❌ application_details[${i}].path is required`); return; } | ||
| apps.push({ name, path, images }); | ||
| } | ||
| } else { | ||
| // Single app mode (inputs) | ||
| const name = (process.env.APPLICATION || '').trim(); | ||
| const path = (process.env.DEPLOY_PATH || '').trim(); | ||
| const images = []; | ||
| if ((process.env.IMG_ONE || '').trim()) { | ||
| images.push(process.env.IMG_ONE.trim()); | ||
| } | ||
| if ((process.env.IMG_LIST || '').trim()) { | ||
| for (const s of process.env.IMG_LIST.split(',').map(x => x.trim()).filter(Boolean)) { | ||
| images.push(s); | ||
| } | ||
| } | ||
| if (!name) { core.setFailed('❌ application is required when application_details is not provided'); return; } | ||
| if (!path) { core.setFailed('❌ deploy_path is required when application_details is not provided'); return; } | ||
| apps.push({ name, path, images }); | ||
| } | ||
| core.setOutput('apps', JSON.stringify(apps)); | ||
| core.setOutput('count', String(apps.length)); | ||
| - name: Download Nunjucks UMD bundle | ||
| if: ${{ inputs.delete_only == false }} | ||
| run: | | ||
| curl -sSL https://cdnjs.cloudflare.com/ajax/libs/nunjucks/3.2.4/nunjucks.min.js -o nunjucks.js | ||
| - name: Render manifests with Nunjucks (Jinja2-style) | ||
| id: render | ||
| if: ${{ inputs.delete_only == false }} | ||
| uses: actions/github-script@v7 | ||
| env: | ||
| APPS: ${{ steps.apps.outputs.apps }} | ||
| CLUSTER_NAME: ${{ steps.env.outputs.cluster }} | ||
| DNS_ZONE: ${{ steps.env.outputs.dns_zone }} | ||
| NAMESPACE: ${{ inputs.namespace }} | ||
| APPLICATION_DEFAULT: ${{ inputs.application }} | ||
| CONTAINER_REGISTRY: ${{ steps.env.outputs.container_registry }} | ||
| UAMI_VARS: ${{ steps.uami.outputs.uami_vars }} | ||
| with: | ||
| script: | | ||
| const fs = require('fs'); | ||
| const path = require('path'); | ||
| const nunjucks = require('./nunjucks.js'); | ||
| const apps = JSON.parse(process.env.APPS || '[]'); | ||
| core.info(`📝 Apps to render: ${apps.map(a => a.name).join(', ')}`); | ||
| let dirs = []; | ||
| // Base vars | ||
| const vars = { | ||
| cluster_name: process.env.CLUSTER_NAME, | ||
| dns_zone: process.env.DNS_ZONE, | ||
| zone_nm: process.env.DNS_ZONE, | ||
| namespace: process.env.NAMESPACE, | ||
| }; | ||
| core.info(`🌍 Base vars: ${JSON.stringify(vars)}`); | ||
| // Merge in UAMI vars (client IDs keyed by uami_name) | ||
| try { | ||
| const uamiVars = JSON.parse(process.env.UAMI_VARS || '{}'); | ||
| Object.assign(vars, uamiVars); | ||
| core.info(`🔑 Injected UAMI vars: ${Object.keys(uamiVars).join(', ')}`); | ||
| } catch (err) { | ||
| core.warning(`⚠️ Could not parse UAMI_VARS: ${err.message}`); | ||
| } | ||
| nunjucks.configure({ autoescape: false }); | ||
| function listFiles(dir) { | ||
| return fs.readdirSync(dir, { withFileTypes: true }) | ||
| .flatMap(e => { | ||
| const full = path.join(dir, e.name); | ||
| return e.isDirectory() ? listFiles(full) : [full]; | ||
| }); | ||
| } | ||
| for (const app of apps) { | ||
| const srcDir = path.join('source', app.path); | ||
| core.startGroup(`📂 Processing app=${app.name} path=${app.path} → srcDir=${srcDir}`); | ||
| dirs.push(srcDir); | ||
| if (!fs.existsSync(srcDir)) { | ||
| core.warning(`⚠️ Source dir not found for app '${app.name}' at ${srcDir}`); | ||
| core.endGroup(); | ||
| continue; | ||
| } | ||
| const files = listFiles(srcDir); | ||
| core.info(`Found ${files.length} files for app '${app.name}'`); | ||
| files.forEach(f => core.info(" - " + f)); | ||
| for (const f of files) { | ||
| if (/\.(ya?ml|json)$/i.test(f) && !/notemplate\.ya?ml$/i.test(f)) { | ||
| const template = fs.readFileSync(f, 'utf8'); | ||
| const rendered = nunjucks.renderString(template, vars); | ||
| fs.writeFileSync(f, rendered, 'utf8'); | ||
| // Detect unresolved placeholders | ||
| if (rendered.includes("{{")) { | ||
| core.warning(`⚠️ Unresolved placeholders remain in ${f}`); | ||
| } | ||
| core.info(`✅ Rendered ${f}`); | ||
| } else { | ||
| core.debug(`Skipping non-YAML/JSON file: ${f}`); | ||
| } | ||
| } | ||
| core.endGroup(); | ||
| } | ||
| return dirs.join("\n"); | ||
| - name: Copy manifests to CD repo | ||
| id: copy | ||
| if: ${{ inputs.delete_only == false }} | ||
| uses: actions/github-script@v7 | ||
| env: | ||
| CLUSTER_NAME: ${{ steps.env.outputs.cluster }} | ||
| NAMESPACE: ${{ inputs.namespace }} | ||
| RENDERED_DIRS: ${{ steps.render.outputs.result }} | ||
| APPS: ${{ steps.apps.outputs.apps }} | ||
| with: | ||
| script: | | ||
| const fs = require('fs'); | ||
| const path = require('path'); | ||
| const { execSync } = require('child_process'); | ||
| const cluster = process.env.CLUSTER_NAME; | ||
| const namespace = process.env.NAMESPACE; | ||
| const renderedDirs = (process.env.RENDERED_DIRS || '').trim().split('\n').filter(Boolean); | ||
| const apps = JSON.parse(process.env.APPS || '[]'); | ||
| // ✅ Copy into the continuous-deployment repo | ||
| const cdRoot = path.join('continuous-deployment', cluster, namespace); | ||
| const cdRootRel = path.join(cluster, namespace); | ||
| fs.mkdirSync(cdRoot, { recursive: true }); | ||
| // assume apps[] order matches renderedDirs[] order | ||
| renderedDirs.forEach((dir, i) => { | ||
| const app = apps[i]; | ||
| if (!app) return; | ||
| const destDir = path.join(cdRoot, app.name); | ||
| // Delete just the app’s directory first | ||
| if (fs.existsSync(destDir)) { | ||
| console.log(`Removing old directory for app ${app.name}: ${destDir}`); | ||
| execSync(`rm -rf "${destDir}"`); | ||
| } | ||
| fs.mkdirSync(destDir, { recursive: true }); | ||
| console.log(`Copying ${dir} -> ${destDir}`); | ||
| execSync(`cp -r "${dir}/." "${destDir}/"`, { stdio: 'inherit' }); | ||
| }); | ||
| core.setOutput('cd_path', cdRoot); | ||
| core.setOutput('cd_path_rel', cdRootRel); | ||
| - name: Create JSON for cluster restore | ||
| id: create-json | ||
| uses: actions/github-script@v7 | ||
| env: | ||
| # use resolved cluster and namespace from earlier step | ||
| CLUSTER: ${{ steps.env.outputs.cluster }} | ||
| NAMESPACE: ${{ steps.env.outputs.namespace || inputs.namespace }} | ||
| APPS: ${{ steps.apps.outputs.apps }} | ||
| CD_REPO: ${{ inputs.cd_repo }} | ||
| CD_REPO_ORG: ${{ inputs.cd_repo_org }} | ||
| with: | ||
| script: | | ||
| const fs = require('fs'); | ||
| const path = require('path'); | ||
| const workspace = process.env.GITHUB_WORKSPACE; | ||
| const cluster = process.env.CLUSTER; | ||
| const namespace = process.env.NAMESPACE || 'default'; | ||
| const apps = JSON.parse(process.env.APPS || '[]'); | ||
| if (!cluster) { | ||
| core.setFailed("❌ Cluster name not resolved from environment config"); | ||
| return; | ||
| } | ||
| if (apps.length === 0) { | ||
| core.warning("⚠️ No applications found — skipping create.json generation"); | ||
| return; | ||
| } | ||
| for (const a of apps) { | ||
| // Absolute path inside the checked-out CD repo | ||
| const outputDir = path.join(workspace, 'continuous-deployment', cluster, namespace, a.name); | ||
| fs.mkdirSync(outputDir, { recursive: true }); | ||
| const payload = { | ||
| cluster, | ||
| namespace, | ||
| applications: [ | ||
| { | ||
| name: a.name, | ||
| repo: `https://github.com/${process.env.CD_REPO_ORG}/${process.env.CD_REPO}`, | ||
| overlay_dir: a.path | ||
| } | ||
| ] | ||
| }; | ||
| const outputFile = path.join(outputDir, 'create.json'); | ||
| fs.writeFileSync(outputFile, JSON.stringify(payload, null, 2)); | ||
| core.info(`🧾 Wrote ${outputFile} for ${a.name} → ${a.path}`); | ||
| } | ||
| core.info(`✅ Finished writing create.json for ${apps.length} app(s)`); | ||
| - name: Setup Kustomize | ||
| if: ${{ inputs.delete_only == false }} | ||
| uses: imranismail/setup-kustomize@v2 | ||
| with: | ||
| kustomize-version: ${{ inputs.kustomize_version }} | ||
| - name: Patch image tag(s) (per app) | ||
| if: ${{ inputs.image_tag != '' && inputs.delete_only == false }} | ||
| uses: actions/github-script@v7 | ||
| env: | ||
| APPS: ${{ steps.apps.outputs.apps }} | ||
| CD_PATH: ${{ steps.copy.outputs.cd_path }} | ||
| IMAGE_TAG: ${{ inputs.image_tag }} | ||
| OVERLAY_DIR: ${{ inputs.overlay_dir }} | ||
| CONTAINER_REGISTRY: ${{ steps.env.outputs.container_registry }} | ||
| with: | ||
| script: | | ||
| const { execSync } = require('child_process'); | ||
| const apps = JSON.parse(process.env.APPS || '[]'); | ||
| const overlayDir = (process.env.OVERLAY_DIR || '').trim(); | ||
| const registry = process.env.CONTAINER_REGISTRY.replace(/\/+$/, ''); // strip trailing slash if any | ||
| for (const app of apps) { | ||
| // Normalized layout: just app.name (+ overlays if defined) | ||
| const base = overlayDir | ||
| ? `${process.env.CD_PATH}/${app.name}/overlays/${overlayDir}` | ||
| : `${process.env.CD_PATH}/${app.name}`; | ||
| if ((app.images || []).length === 0) continue; | ||
| for (const img of app.images) { | ||
| // full replacement: <registry>/<image>:<tag> | ||
| const newImage = `${registry}/${img}:${process.env.IMAGE_TAG}`; | ||
| execSync( | ||
| `bash -lc 'cd "${base}" && kustomize edit set image "${img}=${newImage}"'`, | ||
| { stdio: 'inherit' } | ||
| ); | ||
| } | ||
| } | ||
| - name: Replace old Traefik CRD API version | ||
| if: ${{ inputs.delete_only == false }} | ||
| run: | | ||
| echo "🔎 Replacing traefik.containo.us/v1alpha1 → traefik.io/v1alpha1" | ||
| find "${{ steps.copy.outputs.cd_path }}" -type f \( -name "*.yaml" -o -name "*.yml" \) \ | ||
| -exec sed -i 's#traefik\.containo\.us/v1alpha1#traefik.io/v1alpha1#g' {} + | ||
| - name: Upload CD repo manifests | ||
| if: ${{ always() && inputs.delete_only == false }} | ||
| uses: actions/upload-artifact@v4 | ||
| with: | ||
| name: templated-source-manifests-${{ steps.env.outputs.cluster }}-${{ fromJSON(steps.apps.outputs.apps)[0].name }} | ||
| path: ${{ steps.copy.outputs.cd_path }}/${{ fromJSON(steps.apps.outputs.apps)[0].name }} | ||
| - name: Debug structure | ||
| if: ${{ inputs.debug && inputs.delete_only == false }} | ||
| run: find "${{ steps.copy.outputs.cd_path }}" || echo "Nothing copied!" | ||
| - name: Run kustomize build (concat per app) | ||
| id: kustomize | ||
| if: ${{ inputs.delete_only == false }} | ||
| uses: actions/github-script@v7 | ||
| env: | ||
| APPS: ${{ steps.apps.outputs.apps }} | ||
| CD_PATH: ${{ steps.copy.outputs.cd_path }} | ||
| OVERLAY_DIR: ${{ inputs.overlay_dir }} | ||
| with: | ||
| script: | | ||
| const { execSync } = require('child_process'); | ||
| const fs = require('fs'); | ||
| const apps = JSON.parse(process.env.APPS || '[]'); | ||
| const overlayDir = (process.env.OVERLAY_DIR || '').trim(); | ||
| const outFile = `${process.env.GITHUB_WORKSPACE}/build-output.yaml`; | ||
| fs.writeFileSync(outFile, ''); | ||
| let first = true; | ||
| for (const app of apps) { | ||
| const dir = overlayDir | ||
| ? `${process.env.CD_PATH}/${app.name}/overlays/${overlayDir}` | ||
| : `${process.env.CD_PATH}/${app.name}`; | ||
| const yaml = execSync(`bash -lc 'cd "${dir}" && kustomize build --load-restrictor=LoadRestrictionsNone .'`, { encoding: 'utf8' }); | ||
| fs.appendFileSync(outFile, (first ? '' : '\n---\n') + yaml); | ||
| first = false; | ||
| } | ||
| return outFile; | ||
| - name: Upload built manifest as artifact | ||
| if: ${{ always() && inputs.delete_only == false }} | ||
| uses: actions/upload-artifact@v4 | ||
| with: | ||
| name: built-kustomize-manifest-${{ steps.env.outputs.cluster }}-${{ fromJSON(steps.apps.outputs.apps)[0].name }} | ||
| path: build-output.yaml | ||
| - name: Commit and push to temp branch | ||
| id: commit_deploy | ||
| if: ${{ inputs.delete_only == false }} | ||
| run: | | ||
| set -euo pipefail | ||
| cd continuous-deployment | ||
| echo "🔎 Debug: Current working directory = $(pwd)" | ||
| git remote -v | ||
| git config user.name "k8s-deploy" | ||
| git config user.email "actions@gitopsmanager.com" | ||
| echo "🔎 Debug: Adding files from: ${{ steps.copy.outputs.cd_path_rel }}/" | ||
| ls -la "${{ steps.copy.outputs.cd_path_rel }}/" || echo "⚠️ Directory not found" | ||
| git add -A "${{ steps.copy.outputs.cd_path_rel }}/" | ||
| echo "🔎 Debug: Status after staging:" | ||
| git status --short || true | ||
| git diff --cached --stat || true | ||
| if git diff --cached --quiet; then | ||
| echo "⚠️ No changes detected — creating empty commit" | ||
| git commit --allow-empty -m "No-op deploy commit for ${{ fromJSON(steps.apps.outputs.apps)[0].name }} at $(date -u)" | ||
| else | ||
| git commit -m "Deploy to ${{ steps.env.outputs.cluster }}/${{ inputs.namespace }} for ${{ fromJSON(steps.apps.outputs.apps)[0].name }}" | ||
| fi | ||
| branch="deploy-${{ steps.env.outputs.cluster }}-${{ inputs.namespace }}-${{ fromJSON(steps.apps.outputs.apps)[0].name }}-$(date +%s)" | ||
| echo "🔎 Debug: Branch to push = $branch" | ||
| git log -1 --oneline | ||
| git push --verbose origin HEAD:"$branch" || { echo "❌ git push failed"; exit 1; } | ||
| git ls-remote origin "refs/heads/$branch" || echo "⚠️ Branch not found on remote" | ||
| echo "branch=$branch" >> "$GITHUB_OUTPUT" | ||
| env: | ||
| GIT_TOKEN: ${{ steps.generate_token.outputs.token }} | ||
| - name: Simple retrying API-based squash merge (deploy) | ||
| if: ${{ inputs.delete_only == false }} | ||
| uses: actions/github-script@v7 | ||
| with: | ||
| github-token: ${{ steps.generate_token.outputs.token }} | ||
| script: | | ||
| const owner = process.env.CD_REPO_ORG; | ||
| const repo = process.env.CD_REPO; | ||
| const branch = process.env.BRANCH; | ||
| const appName = process.env.APP_NAME; | ||
| async function wait(ms) { return new Promise(r => setTimeout(r, ms)); } | ||
| const fixedRetries = 10; // 2s interval | ||
| const randomRetries = 20; // 1–7.5s randomized | ||
| const maxRetries = fixedRetries + randomRetries; | ||
| let prNumber = null; | ||
| let merged = false; | ||
| try { | ||
| // --- Detect base branch --- | ||
| const { data: repoInfo } = await github.rest.repos.get({ owner, repo }); | ||
| const baseBranch = repoInfo.default_branch; | ||
| core.info(`ℹ️ Default branch = ${baseBranch}`); | ||
| // --- Create PR --- | ||
| const { data: pr } = await github.rest.pulls.create({ | ||
| owner, | ||
| repo, | ||
| head: branch, | ||
| base: baseBranch, | ||
| title: `Deploy ${appName} to ${process.env.CLUSTER}/${process.env.NAMESPACE}`, | ||
| body: "Automated deployment update." | ||
| }); | ||
| prNumber = pr.number; | ||
| core.info(`✅ Opened PR #${pr.number}`); | ||
| // --- Attempt merge loop --- | ||
| for (let attempt = 1; attempt <= maxRetries; attempt++) { | ||
| try { | ||
| await github.rest.pulls.merge({ | ||
| owner, | ||
| repo, | ||
| pull_number: pr.number, | ||
| merge_method: "squash" | ||
| }); | ||
| core.info(`✅ Squash-merged PR #${pr.number} on attempt ${attempt}`); | ||
| merged = true; | ||
| break; | ||
| } catch (err) { | ||
| const msg = err.message || ""; | ||
| core.warning(`⚠️ Merge attempt ${attempt} failed: ${msg}`); | ||
| // Hard conflict — same-file edits — fail immediately | ||
| if (/conflict|cannot.*merge.*due to conflicts/i.test(msg)) { | ||
| core.setFailed(`❌ Real merge conflict detected for PR #${pr.number}.`); | ||
| break; | ||
| } | ||
| // Otherwise, retry (transient base-modified/unknown state) | ||
| if (attempt <= fixedRetries) { | ||
| await wait(2000); // first 10 tries = fixed 2s | ||
| } else { | ||
| const delay = 1000 + Math.random() * 6500; // 1–7.5s | ||
| core.info(`⏳ Retrying in ${(delay / 1000).toFixed(1)}s...`); | ||
| await wait(delay); | ||
| } | ||
| } | ||
| } | ||
| if (!merged) { | ||
| core.setFailed(`❌ Could not squash-merge PR #${pr.number} after ${maxRetries} attempts.`); | ||
| } | ||
| } catch (err) { | ||
| core.setFailed(`❌ Error during deploy PR merge process: ${err.message || err}`); | ||
| } finally { | ||
| // --- Always cleanup --- | ||
| if (prNumber) { | ||
| if (!merged) { | ||
| core.info(`🧹 Cleanup: leaving PR #${prNumber} open as it failed and deleting branch '${branch}'...`); | ||
| } | ||
| // --- Guard protected branches from deletion --- | ||
| const protectedBranches = ["main", "master"]; | ||
| if (protectedBranches.includes(branch)) { | ||
| core.warning(`🚫 Skipping deletion of protected branch '${branch}'.`); | ||
| } else { | ||
| try { | ||
| await github.rest.git.deleteRef({ | ||
| owner, repo, ref: `heads/${branch}` | ||
| }); | ||
| core.info(`🗑️ Deleted branch '${branch}'.`); | ||
| } catch (e) { | ||
| core.warning(`⚠️ Could not delete branch '${branch}': ${e.message}`); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| env: | ||
| CD_REPO_ORG: ${{ inputs.cd_repo_org }} | ||
| CD_REPO: ${{ inputs.cd_repo }} | ||
| BRANCH: ${{ steps.commit_deploy.outputs.branch }} | ||
| CLUSTER: ${{ steps.env.outputs.cluster }} | ||
| NAMESPACE: ${{ inputs.namespace }} | ||
| APP_NAME: ${{ fromJSON(steps.apps.outputs.apps)[0].name }} | ||
| - name: Resolve ArgoCD auth | ||
| id: resolve_auth | ||
| env: | ||
| IN_TOKEN: ${{ inputs.argocd_auth_token }} | ||
| IN_USER: ${{ inputs.argocd_username }} | ||
| IN_PASS: ${{ inputs.argocd_password }} | ||
| SEC_USER: ${{ secrets.ARGOCD_USERNAME }} | ||
| SEC_PASS: ${{ secrets.ARGOCD_PASSWORD }} | ||
| run: | | ||
| set -euo pipefail | ||
| if [ -n "${IN_TOKEN:-}" ]; then | ||
| echo "mode=token" >> "$GITHUB_OUTPUT" | ||
| echo "token=$IN_TOKEN" >> "$GITHUB_OUTPUT" | ||
| elif [ -n "${IN_USER:-}" ] && [ -n "${IN_PASS:-}" ]; then | ||
| echo "mode=basic" >> "$GITHUB_OUTPUT" | ||
| echo "username=$IN_USER" >> "$GITHUB_OUTPUT" | ||
| echo "password=$IN_PASS" >> "$GITHUB_OUTPUT" | ||
| elif [ -n "${SEC_USER:-}" ] && [ -n "${SEC_PASS:-}" ]; then | ||
| echo "mode=basic" >> "$GITHUB_OUTPUT" | ||
| echo "username=$SEC_USER" >> "$GITHUB_OUTPUT" | ||
| echo "password=$SEC_PASS" >> "$GITHUB_OUTPUT" | ||
| else | ||
| echo "❌ No ArgoCD auth provided." | ||
| exit 1 | ||
| fi | ||
| - name: Debug ARGOCD_CA_CERT presence | ||
| run: | | ||
| if [ -z "${ARGOCD_CA_CERT}" ]; then | ||
| echo "❌ ARGOCD_CA_CERT is not set" | ||
| else | ||
| echo "✅ ARGOCD_CA_CERT is set (length: ${#ARGOCD_CA_CERT})" | ||
| fi | ||
| shell: bash | ||
| env: | ||
| ARGOCD_CA_CERT: ${{ secrets.ARGOCD_CA_CERT }} | ||
| - name: Set ArgoCD connection (token & URLs) | ||
| id: argocd_conn | ||
| uses: actions/github-script@v7 | ||
| env: | ||
| MODE: ${{ steps.resolve_auth.outputs.mode }} | ||
| TOKEN: ${{ steps.resolve_auth.outputs.token }} | ||
| USERNAME: ${{ steps.resolve_auth.outputs.username }} | ||
| PASSWORD: ${{ steps.resolve_auth.outputs.password }} | ||
| ARGOCD_CA_CERT: ${{ secrets.ARGOCD_CA_CERT }} | ||
| CLUSTER: ${{ steps.env.outputs.cluster }} | ||
| DNS_ZONE: ${{ steps.env.outputs.dns_zone }} | ||
| INSECURE_ARGO: ${{ inputs.insecure_argo }} | ||
| with: | ||
| script: | | ||
| const { execSync } = require('child_process'); | ||
| const fs = require('fs'); | ||
| const cluster = process.env.CLUSTER; | ||
| const dnsZone = process.env.DNS_ZONE; | ||
| const argocdUrl = `https://${cluster}-argocd-argocd-web-ui.${dnsZone}`; | ||
| // TLS flags for curl | ||
| let curlSslFlags = ""; | ||
| if (String(process.env.INSECURE_ARGO) === "true") { | ||
| curlSslFlags = "-k"; | ||
| core.warning("⚠️ Using insecure connection (curl -k)."); | ||
| } else if (process.env.ARGOCD_CA_CERT) { | ||
| let cert = process.env.ARGOCD_CA_CERT; | ||
| if (cert.includes('\\n')) cert = cert.replace(/\\n/g, '\n'); | ||
| fs.writeFileSync('/tmp/argocd-ca.crt', cert); | ||
| curlSslFlags = "--cacert /tmp/argocd-ca.crt"; | ||
| core.info("✅ Using provided CA cert with curl."); | ||
| } | ||
| let finalToken = process.env.TOKEN; | ||
| if (process.env.MODE !== "token") { | ||
| const body = JSON.stringify({ username: process.env.USERNAME, password: process.env.PASSWORD }); | ||
| const cmd = `curl -s ${curlSslFlags} -X POST "${argocdUrl}/api/v1/session" -H "Content-Type: application/json" -d '${body.replace(/'/g,"'\\''")}'`; | ||
| core.info(`🔐 Running: curl ... (hidden password)`); | ||
| const resp = execSync(cmd).toString(); | ||
| try { | ||
| finalToken = JSON.parse(resp).token; | ||
| } catch { | ||
| core.error(`Response: ${resp}`); | ||
| core.setFailed("❌ Failed to parse ArgoCD session response"); | ||
| return; | ||
| } | ||
| } | ||
| if (!finalToken) { | ||
| core.setFailed("❌ Failed to obtain ArgoCD token."); | ||
| return; | ||
| } | ||
| core.info("✅ Successfully obtained ArgoCD token."); | ||
| core.setOutput('argocd_url', argocdUrl); | ||
| core.setOutput('curl_ssl_flags', curlSslFlags); | ||
| core.setOutput('token', finalToken); | ||
| - name: Delete ArgoCD apps (per app) | ||
| if: ${{ inputs.delete_first || inputs.delete_only }} | ||
| uses: actions/github-script@v7 | ||
| env: | ||
| APPS: ${{ steps.apps.outputs.apps }} | ||
| ARGOCD_URL: ${{ steps.argocd_conn.outputs.argocd_url }} | ||
| ARGOCD_TOKEN: ${{ steps.argocd_conn.outputs.token }} | ||
| CURL_SSL_FLAGS: ${{ steps.argocd_conn.outputs.curl_ssl_flags }} | ||
| NAMESPACE: ${{ inputs.namespace }} | ||
| with: | ||
| script: | | ||
| const { execSync } = require('child_process'); | ||
| const apps = JSON.parse(process.env.APPS || '[]'); | ||
| function httpCode(cmd) { | ||
| return parseInt(execSync(`${cmd} -o /dev/null -w "%{http_code}"`).toString().trim(), 10); | ||
| } | ||
| for (const app of apps) { | ||
| const appName = `${process.env.NAMESPACE}-${app.name}`; | ||
| const appUrl = `${process.env.ARGOCD_URL}/api/v1/applications/${appName}`; | ||
| core.info(`🗑️ Deleting ArgoCD Application: ${appName}`); | ||
| const delCmd = `curl -s ${process.env.CURL_SSL_FLAGS} -X DELETE "${appUrl}" -H "Authorization: Bearer ${process.env.ARGOCD_TOKEN}" -H "Content-Type: application/json"`; | ||
| const code = httpCode(delCmd); | ||
| core.info(`Delete response HTTP ${code}`); | ||
| if (![200, 202, 204].includes(code)) { | ||
| core.setFailed(`❌ Failed to delete ${appName}: HTTP ${code}`); | ||
| return; | ||
| } | ||
| const timeout = Date.now() + 120000; | ||
| while (Date.now() < timeout) { | ||
| const checkCmd = `curl -s ${process.env.CURL_SSL_FLAGS} -H "Authorization: Bearer ${process.env.ARGOCD_TOKEN}" "${appUrl}"`; | ||
| const checkCode = httpCode(checkCmd); | ||
| if (checkCode === 403) { | ||
| core.info(`✅ ${appName} deleted (HTTP 403)`); | ||
| break; | ||
| } | ||
| core.info(`⏳ Still deleting ${appName} (HTTP ${checkCode})...`); | ||
| await new Promise(r => setTimeout(r, 5000)); | ||
| } | ||
| } | ||
| - name: Clear apps from CD repo (delete_only mode) | ||
| if: ${{ inputs.delete_only }} | ||
| uses: actions/github-script@v7 | ||
| env: | ||
| CD_PATH_REL: ${{ steps.cdroot.outputs.cd_root }} | ||
| APPS: ${{ steps.apps.outputs.apps }} | ||
| with: | ||
| script: | | ||
| const fs = require('fs'); | ||
| const path = require('path'); | ||
| const { execSync } = require('child_process'); | ||
| const apps = JSON.parse(process.env.APPS || '[]'); | ||
| const cdRootRel = process.env.CD_PATH_REL; | ||
| for (const app of apps) { | ||
| const appDir = path.join('continuous-deployment', cdRootRel, app.name); | ||
| if (fs.existsSync(appDir)) { | ||
| core.info(`🗑️ Removing app directory: ${appDir}`); | ||
| execSync(`rm -rf "${appDir}"`); | ||
| } else { | ||
| core.info(`⚠️ Directory not found for app: ${app.name}`); | ||
| } | ||
| } | ||
| core.setOutput('cd_path_rel', cdRootRel); | ||
| - name: Commit and push deletions to temp branch | ||
| id: commit_delete | ||
| if: ${{ inputs.delete_only }} | ||
| run: | | ||
| set -euo pipefail | ||
| cd continuous-deployment | ||
| echo "🔎 Debug: Current working directory = $(pwd)" | ||
| git remote -v | ||
| git config user.name "k8s-deploy" | ||
| git config user.email "actions@gitopsmanager.com" | ||
| echo "Staging deletions in: ${{ steps.cdroot.outputs.cd_root }}/" | ||
| git rm -r "${{ steps.cdroot.outputs.cd_root }}/" 2>/dev/null || true | ||
| echo "🔎 Debug: Status after staging:" | ||
| git status --short || true | ||
| git diff --cached --stat || true | ||
| if git diff --cached --quiet; then | ||
| echo "⚠️ No deletions detected — creating empty commit" | ||
| git commit --allow-empty -m "No-op delete commit for ${{ fromJSON(steps.apps.outputs.apps)[0].name }} at $(date -u)" | ||
| else | ||
| git commit -m "Delete apps in ${{ steps.env.outputs.cluster }}/${{ inputs.namespace }} for ${{ fromJSON(steps.apps.outputs.apps)[0].name }} (delete_only mode)" | ||
| fi | ||
| branch="delete-${{ steps.env.outputs.cluster }}-${{ inputs.namespace }}-${{ fromJSON(steps.apps.outputs.apps)[0].name }}-$(date +%s)" | ||
| echo "🔎 Debug: Branch to push = $branch" | ||
| git log -1 --oneline | ||
| git push --verbose origin HEAD:"$branch" || { echo "❌ git push failed"; exit 1; } | ||
| git ls-remote origin "refs/heads/$branch" || echo "⚠️ Branch not found on remote" | ||
| echo "branch=$branch" >> "$GITHUB_OUTPUT" | ||
| env: | ||
| GIT_TOKEN: ${{ steps.generate_token.outputs.token }} | ||
| - name: Create and squash-merge PR (delete-only) | ||
| if: ${{ inputs.delete_only }} | ||
| uses: actions/github-script@v7 | ||
| with: | ||
| github-token: ${{ steps.generate_token.outputs.token }} | ||
| script: | | ||
| const owner = process.env.CD_REPO_ORG; | ||
| const repo = process.env.CD_REPO; | ||
| const branch = process.env.BRANCH; | ||
| const appName = process.env.APP_NAME; | ||
| // Detect default branch dynamically | ||
| const { data: repoInfo } = await github.rest.repos.get({ owner, repo }); | ||
| const baseBranch = repoInfo.default_branch; | ||
| core.info(`ℹ️ Default branch for ${owner}/${repo} = ${baseBranch}`); | ||
| // Create PR | ||
| const { data: pr } = await github.rest.pulls.create({ | ||
| owner, | ||
| repo, | ||
| head: branch, | ||
| base: baseBranch, | ||
| title: `Delete ${appName} in ${process.env.CLUSTER}/${process.env.NAMESPACE}`, | ||
| body: "Automated delete-only deployment update." | ||
| }); | ||
| core.info(`✅ Opened delete-only PR #${pr.number}`); | ||
| // Poll until mergeable state is computed | ||
| let mergeable = null; | ||
| let mergeableState = null; | ||
| for (let i = 0; i < 10; i++) { | ||
| const { data: prStatus } = await github.rest.pulls.get({ | ||
| owner, | ||
| repo, | ||
| pull_number: pr.number | ||
| }); | ||
| mergeable = prStatus.mergeable; | ||
| mergeableState = prStatus.mergeable_state; | ||
| core.info(`ℹ️ Attempt ${i+1}: mergeable=${mergeable}, state=${mergeableState}`); | ||
| if (mergeable !== null) break; | ||
| await new Promise(r => setTimeout(r, 2000)); | ||
| } | ||
| if (mergeable === false) { | ||
| core.setFailed(`❌ PR #${pr.number} is not mergeable (state=${mergeableState}).`); | ||
| } else if (mergeable === null) { | ||
| core.setFailed(`❌ PR #${pr.number} mergeability never resolved.`); | ||
| } else { | ||
| // 🔁 Retry loop for transient "base modified" errors | ||
| const maxRetries = 10; | ||
| let merged = false; | ||
| for (let i = 0; i < maxRetries; i++) { | ||
| try { | ||
| await github.rest.pulls.merge({ | ||
| owner, | ||
| repo, | ||
| pull_number: pr.number, | ||
| merge_method: "squash" | ||
| }); | ||
| core.info(`✅ Squash-merged delete-only PR #${pr.number} on attempt ${i+1}`); | ||
| merged = true; | ||
| break; | ||
| } catch (err) { | ||
| if (err.message && err.message.includes("Base branch was modified")) { | ||
| core.warning(`⚠️ Attempt ${i+1}: base branch changed, retrying in 2s...`); | ||
| await new Promise(r => setTimeout(r, 2000)); | ||
| continue; | ||
| } | ||
| throw err; // unexpected failure | ||
| } | ||
| } | ||
| if (!merged) { | ||
| core.setFailed(`❌ Could not squash-merge delete-only PR #${pr.number} after ${maxRetries} retries`); | ||
| } | ||
| } | ||
| env: | ||
| CD_REPO_ORG: ${{ inputs.cd_repo_org }} | ||
| CD_REPO: ${{ inputs.cd_repo }} | ||
| BRANCH: ${{ steps.commit_delete.outputs.branch }} | ||
| CLUSTER: ${{ steps.env.outputs.cluster }} | ||
| NAMESPACE: ${{ inputs.namespace }} | ||
| APP_NAME: ${{ fromJSON(steps.apps.outputs.apps)[0].name }} | ||
| - name: Check or create ArgoCD applications (per app) | ||
| uses: actions/github-script@v7 | ||
| if: ${{ inputs.delete_only == false }} | ||
| env: | ||
| APPS: ${{ steps.apps.outputs.apps }} | ||
| CD_PATH_REL: ${{ steps.copy.outputs.cd_path_rel }} | ||
| OVERLAY_DIR: ${{ inputs.overlay_dir }} | ||
| CD_REPO: ${{ inputs.cd_repo }} | ||
| CD_REPO_ORG: ${{ inputs.cd_repo_org }} | ||
| ARGOCD_URL: ${{ steps.argocd_conn.outputs.argocd_url }} | ||
| ARGOCD_TOKEN: ${{ steps.argocd_conn.outputs.token }} | ||
| CURL_SSL_FLAGS: ${{ steps.argocd_conn.outputs.curl_ssl_flags }} | ||
| NAMESPACE: ${{ inputs.namespace }} | ||
| with: | ||
| script: | | ||
| const { execSync } = require('child_process'); | ||
| const fs = require('fs'); | ||
| const path = require('path'); | ||
| const nunjucks = require('./nunjucks.js'); | ||
| const apps = JSON.parse(process.env.APPS || '[]'); | ||
| function curlJson(url) { | ||
| return execSync(`curl -s ${process.env.CURL_SSL_FLAGS} -H "Authorization: Bearer ${process.env.ARGOCD_TOKEN}" "${url}"`).toString(); | ||
| } | ||
| function deleteApp(appUrl, appName) { | ||
| execSync(`curl -s -o /dev/null -w "%{http_code}" ${process.env.CURL_SSL_FLAGS} -X DELETE "${appUrl}" -H "Authorization: Bearer ${process.env.ARGOCD_TOKEN}" -H "Content-Type: application/json"`); | ||
| const timeout = Date.now() + 120000; | ||
| while (Date.now() < timeout) { | ||
| const code = parseInt(execSync(`curl -s -o /dev/null -w "%{http_code}" ${process.env.CURL_SSL_FLAGS} -H "Authorization: Bearer ${process.env.ARGOCD_TOKEN}" "${appUrl}"`).toString()); | ||
| if (code === 403) return true; | ||
| new Promise(r => setTimeout(r, 5000)); | ||
| } | ||
| return false; | ||
| } | ||
| for (const app of apps) { | ||
| const appName = `${process.env.NAMESPACE}-${app.name}`; | ||
| const statusUrl = `${process.env.ARGOCD_URL}/api/v1/applications/${appName}`; | ||
| const basePath = path.join(process.env.CD_PATH_REL, app.name, 'overlays', process.env.OVERLAY_DIR); | ||
| const status = curlJson(statusUrl); | ||
| if (status) { | ||
| try { | ||
| const existing = JSON.parse(status); | ||
| if (existing?.spec?.source?.path !== basePath) { | ||
| core.warning(`⚠️ Path mismatch for ${appName}, recreating`); | ||
| deleteApp(statusUrl, appName); | ||
| } else { | ||
| core.info(`✅ Argo app ${appName} exists with correct path.`); | ||
| continue; | ||
| } | ||
| } catch { | ||
| // treat as non-200 | ||
| } | ||
| } | ||
| const template = fs.readFileSync('reusable/templates/argocd-app-template-v6.json', 'utf8'); | ||
| const rendered = nunjucks.renderString(template, { | ||
| APP_NAME: appName, | ||
| NAMESPACE: process.env.NAMESPACE, | ||
| CD_REPO: process.env.CD_REPO, | ||
| CD_REPO_ORG: process.env.CD_REPO_ORG, | ||
| CD_PATH: basePath | ||
| }); | ||
| execSync(`curl -s ${process.env.CURL_SSL_FLAGS} -X POST "${process.env.ARGOCD_URL}/api/v1/applications" -H "Authorization: Bearer ${process.env.ARGOCD_TOKEN}" -H "Content-Type: application/json" -d '${rendered.replace(/'/g,"'\\''")}'`); | ||
| core.info(`✅ Created Argo app ${appName}`); | ||
| } | ||
| - name: Sync ArgoCD apps (per app) | ||
| id: sync | ||
| if: ${{ inputs.delete_only == false }} | ||
| uses: actions/github-script@v7 | ||
| env: | ||
| APPS: ${{ steps.apps.outputs.apps }} | ||
| ARGOCD_URL: ${{ steps.argocd_conn.outputs.argocd_url }} | ||
| ARGOCD_TOKEN: ${{ steps.argocd_conn.outputs.token }} | ||
| CURL_SSL_FLAGS: ${{ steps.argocd_conn.outputs.curl_ssl_flags }} | ||
| NAMESPACE: ${{ inputs.namespace }} | ||
| with: | ||
| script: | | ||
| const { execSync } = require('child_process'); | ||
| const apps = JSON.parse(process.env.APPS || '[]'); | ||
| const baselines = {}; | ||
| for (const app of apps) { | ||
| const appName = `${process.env.NAMESPACE}-${app.name}`; | ||
| const statusUrl = `${process.env.ARGOCD_URL}/api/v1/applications/${appName}`; | ||
| let baseline = 0; | ||
| try { | ||
| const status = execSync(`curl -s ${process.env.CURL_SSL_FLAGS} -H "Authorization: Bearer ${process.env.ARGOCD_TOKEN}" "${statusUrl}"`).toString(); | ||
| const json = JSON.parse(status); | ||
| if (json?.status?.operationState?.startedAt) { | ||
| baseline = new Date(json.status.operationState.startedAt).getTime(); | ||
| } | ||
| } catch {} | ||
| baselines[appName] = baseline; | ||
| // 🧨 Force prune sync | ||
| execSync(`curl -s ${process.env.CURL_SSL_FLAGS} -X POST "${statusUrl}/sync" \ | ||
| -H "Authorization: Bearer ${process.env.ARGOCD_TOKEN}" \ | ||
| -H "Content-Type: application/json" \ | ||
| -d '{"prune": true, "force": true}'`); | ||
| core.info(`🚀 Force-prune sync triggered: ${appName}`); | ||
| } | ||
| core.setOutput('baselines', JSON.stringify(baselines)); | ||
| - name: Wait for ArgoCD sync (per app) | ||
| if: ${{ inputs.skip_status_check == false && inputs.delete_only == false }} | ||
| uses: actions/github-script@v7 | ||
| env: | ||
| APPS: ${{ steps.apps.outputs.apps }} | ||
| ARGOCD_URL: ${{ steps.argocd_conn.outputs.argocd_url }} | ||
| ARGOCD_TOKEN: ${{ steps.argocd_conn.outputs.token }} | ||
| CURL_SSL_FLAGS: ${{ steps.argocd_conn.outputs.curl_ssl_flags }} | ||
| NAMESPACE: ${{ inputs.namespace }} | ||
| BASELINES: ${{ steps.sync.outputs.baselines }} | ||
| with: | ||
| script: | | ||
| const { execSync } = require('child_process'); | ||
| const apps = JSON.parse(process.env.APPS || '[]'); | ||
| const baselines = JSON.parse(process.env.BASELINES || '{}'); | ||
| // Helper: determine if all resources are synced | ||
| function allResourcesSynced(json) { | ||
| const results = | ||
| json?.status?.operationState?.syncResult?.resources || | ||
| json?.status?.operationState?.results || | ||
| []; | ||
| return ( | ||
| Array.isArray(results) && | ||
| results.length > 0 && | ||
| results.every(r => r.status === 'Synced' || r.hookPhase === 'Succeeded') | ||
| ); | ||
| } | ||
| for (const app of apps) { | ||
| const appName = `${process.env.NAMESPACE}-${app.name}`; | ||
| const statusUrl = `${process.env.ARGOCD_URL}/api/v1/applications/${appName}`; | ||
| const baseline = baselines[appName] || 0; | ||
| core.startGroup(`⏳ Waiting for sync of ${appName}, baseline=${baseline}`); | ||
| let ok = false; | ||
| let lastPhase, lastSync, lastHealth; | ||
| let consecutiveErrors = 0; | ||
| for (let i = 0; i < 36; i++) { | ||
| try { | ||
| const status = execSync( | ||
| `curl -s ${process.env.CURL_SSL_FLAGS} -H "Authorization: Bearer ${process.env.ARGOCD_TOKEN}" "${statusUrl}"` | ||
| ).toString(); | ||
| const json = JSON.parse(status); | ||
| const op = json?.status?.operationState; | ||
| lastPhase = op?.phase; | ||
| lastSync = json?.status?.sync?.status; | ||
| lastHealth = json?.status?.health?.status; | ||
| core.info( | ||
| `🔄 Iteration ${i + 1}/36 for ${appName}: phase=${lastPhase}, sync=${lastSync}, health=${lastHealth}` | ||
| ); | ||
| consecutiveErrors = 0; // reset error counter if fetch succeeded | ||
| // Immediate fail conditions | ||
| if (lastPhase === 'Error' || lastSync === 'Error' || lastSync === 'Failed') { | ||
| core.setFailed(`❌ Sync failed for ${appName} (phase=${lastPhase}, sync=${lastSync})`); | ||
| return; | ||
| } | ||
| const startedAt = op?.startedAt ? new Date(op.startedAt).getTime() : 0; | ||
| const allSynced = allResourcesSynced(json); | ||
| // Case 1: new operation started after baseline | ||
| if (startedAt > baseline) { | ||
| if (lastHealth === 'Degraded') { | ||
| core.setFailed(`❌ ${appName} is Synced but health=Degraded.`); | ||
| return; | ||
| } | ||
| // Standard success condition | ||
| if (lastPhase === 'Succeeded' && lastSync === 'Synced' && lastHealth === 'Healthy') { | ||
| core.info(`✅ ${appName} synced after baseline and is Healthy.`); | ||
| ok = true; | ||
| break; | ||
| } | ||
| // New condition: All resources synced, app healthy | ||
| else if (allSynced && lastHealth === 'Healthy') { | ||
| core.info(`✅ ${appName} has all synced results and is Healthy.`); | ||
| ok = true; | ||
| break; | ||
| } | ||
| // Waiting state | ||
| else if (lastSync === 'Synced') { | ||
| core.info(`⏳ ${appName} is Synced but health=${lastHealth} → waiting for Healthy...`); | ||
| } | ||
| } | ||
| // Case 2: no new operation, but app is already good | ||
| if (startedAt <= baseline) { | ||
| if (lastSync === 'Synced' && lastHealth === 'Healthy') { | ||
| core.info(`✅ ${appName} was already Synced and Healthy before baseline.`); | ||
| ok = true; | ||
| break; | ||
| } | ||
| } | ||
| } catch (err) { | ||
| consecutiveErrors++; | ||
| if (consecutiveErrors > 3) { | ||
| core.setFailed( | ||
| `❌ Failed to fetch status for ${appName} after ${consecutiveErrors} attempts: ${err.message}` | ||
| ); | ||
| return; | ||
| } else { | ||
| core.warning( | ||
| `⚠️ Failed to fetch status for ${appName} (attempt ${consecutiveErrors}): ${err.message}` | ||
| ); | ||
| } | ||
| } | ||
| await new Promise((r) => setTimeout(r, 10000)); | ||
| } | ||
| if (!ok) { | ||
| core.setFailed( | ||
| `❌ Sync did not complete in time for ${appName} (last phase=${lastPhase}, sync=${lastSync}, health=${lastHealth})` | ||
| ); | ||
| } | ||
| core.endGroup(); | ||
| } | ||