Skip to content

Update deploy.yaml

Update deploy.yaml #32

Workflow file for this run

# deploy.yaml

Check failure on line 1 in .github/workflows/deploy.yaml

View workflow run for this annotation

GitHub Actions / .github/workflows/deploy.yaml

Invalid workflow file

(Line: 793, Col: 11): A mapping was not expected, (Line: 1077, Col: 11): A mapping was not expected
# 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();
}