Update deploy.yaml #32
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: 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)) { | ||
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: 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 .'`, { 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-${{ fromJSON(steps.apps.outputs.apps)[0].name }}.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 at $(date -u)" | ||
else | ||
git commit -m "Deploy to ${{ steps.env.outputs.cluster }}/${{ inputs.namespace }} (apps: ${{ steps.apps.outputs.count }})" | ||
fi | ||
branch="deploy-${{ steps.env.outputs.cluster }}-${{ inputs.namespace }}-$(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 (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; | ||
// 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: `Deploy to ${process.env.CLUSTER}/${process.env.NAMESPACE}`, | ||
body: "Automated deployment update." | ||
}); | ||
core.info(`✅ Opened 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 (mergeableState === "behind") { | ||
core.info("🔄 Branch is behind base, updating with latest base branch..."); | ||
await github.rest.repos.merge({ | ||
owner, | ||
repo, | ||
base: branch, // update feature branch | ||
head: baseBranch // merge base into it | ||
}); | ||
} | ||
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 { | ||
await github.rest.pulls.merge({ | ||
owner, | ||
repo, | ||
pull_number: pr.number, | ||
merge_method: "squash" | ||
}); | ||
core.info(`✅ Squash-merged PR #${pr.number}`); | ||
} | ||
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 }} | ||
- 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 at $(date -u)" | ||
else | ||
git commit -m "Delete apps in ${{ steps.env.outputs.cluster }}/${{ inputs.namespace }} (delete_only mode)" | ||
fi | ||
branch="delete-${{ steps.env.outputs.cluster }}-${{ inputs.namespace }}-$(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; | ||
// 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 apps 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 (mergeableState === "behind") { | ||
core.info("🔄 Branch is behind base, updating with latest base branch..."); | ||
await github.rest.repos.merge({ | ||
owner, | ||
repo, | ||
base: branch, // update the PR branch | ||
head: baseBranch // merge base branch into it | ||
}); | ||
} | ||
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 { | ||
await github.rest.pulls.merge({ | ||
owner, | ||
repo, | ||
pull_number: pr.number, | ||
merge_method: "squash" | ||
}); | ||
core.info(`✅ Squash-merged delete-only PR #${pr.number}`); | ||
} | ||
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 }} | ||
- 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-v5.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; | ||
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 '{}'`); | ||
core.info(`🚀 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 || '{}'); | ||
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 < 18; 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}/18 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; | ||
} | ||
// Only check once operation has started after baseline | ||
if (op?.startedAt && new Date(op.startedAt).getTime() > baseline) { | ||
if (lastPhase === 'Succeeded' && lastSync === 'Synced') { | ||
if (lastHealth === 'Healthy') { | ||
core.info(`✅ ${appName} is Synced and Healthy.`); | ||
ok = true; | ||
break; | ||
} else if (lastHealth === 'Degraded') { | ||
core.setFailed(`❌ ${appName} is Synced but health=Degraded.`); | ||
return; | ||
} else { | ||
core.info(`⏳ ${appName} is Synced but health=${lastHealth} → waiting for Healthy...`); | ||
} | ||
} | ||
} | ||
} 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(); | ||
} | ||