Skip to content

Update deploy.yaml

Update deploy.yaml #27

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: 899, Col: 11): Unrecognized function: 'not'. Located at position 1 within expression: not(inputs.skip_status_check)
# 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:
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
cd_repo:
required: true
type: string
cd_repo_org:
required: true
type: string
github_environment:
required: true
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:
runs-on: ${{ inputs.github_runner }}
environment: ${{ inputs.github_environment }}
outputs:
cd_path: ${{ steps.cdroot.outputs.cd_root }}
steps:
- name: Checkout repo
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
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
uses: gitopsmanager/detect-cloud@main
with:
timeout-ms: 800
- name: Warn if GitHub-hosted (unknown cloud)
if: ${{ steps.cloud.outputs.provider == 'unknown' }}
run: |
echo "⚠️ Running on a GitHub-hosted runner."
echo "Workload Identity mappings for AWS/Azure self-hosted runners will not apply."
# 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 }}
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;
const clusterIn = (process.env.CLUSTER_IN || '').trim();
if (!Object.prototype.hasOwnProperty.call(map, envName)) {
core.setFailed(`❌ Environment '${envName}' not found. Available: ${Object.keys(map).join(', ')}`);
return;
}
let selected;
if (clusterIn) {
// 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;
}
core.info(`Cluster override: using cluster '${selected.cluster}' from env '${selected.__env}'`);
} else {
// Fallback: normal target-based selection
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}'`);
}
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"
# Export UAMI env vars (uami_name => client_id)
- name: Export UAMI env vars (uami_name => client_id)
uses: actions/github-script@v7
env:
UAMI_JSON: ${{ steps.env.outputs.uami_map }}
CLUSTER: ${{ steps.env.outputs.cluster }}
with:
script: |
const fs = require('fs');
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.');
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;
}
fs.appendFileSync(process.env.GITHUB_ENV, `${varName}=${cid}\n`);
seen.add(varName);
exported.push({ varName, cid });
}
if (exported.length === 0) {
core.info("No UAMI vars exported.");
} else {
core.info("Exported UAMI vars for templating:");
for (const e of exported) {
core.info(` ${e.varName}=${e.cid}`);
}
}
# 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; }
if (!Array.isArray(arr)) { core.setFailed('❌ application_details must be a JSON array'); return; }
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
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
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
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 }}
with:
script: |
const fs = require('fs');
const path = require('path');
const nunjucks = require('./nunjucks.js');
const apps = JSON.parse(process.env.APPS || '[]');
let dirs = [];
const vars = {
CLUSTER_NAME: process.env.CLUSTER_NAME,
DNS_ZONE: process.env.DNS_ZONE,
NAMESPACE: process.env.NAMESPACE,
APPLICATION: process.env.APPLICATION_DEFAULT,
CONTAINER_REGISTRY: process.env.CONTAINER_REGISTRY,
};
nunjucks.configure({ autoescape: false });
for (const app of apps) {
const srcDir = path.join('source', app.path);
dirs.push(srcDir);
const files = fs.existsSync(srcDir)
? fs.readdirSync(srcDir, { withFileTypes: true }).map(e => path.join(srcDir, e.name))
: [];
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');
console.log(`Rendered ${f}`);
}
}
}
// github-script: anything you return is exposed as "steps.render.outputs.result"
return dirs.join("\n");
- name: Copy manifests to CD repo
id: copy
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: Upload CD repo manifests
if: always()
uses: actions/upload-artifact@v4
with:
name: templated-source-manifests-${{ steps.env.outputs.cluster }}
path: ${{ steps.copy.outputs.cd_path }}
- name: Debug structure
if: ${{ inputs.debug }}
run: find "${{ steps.copy.outputs.cd_path }}" || echo "Nothing copied!"
- name: Setup Kustomize
uses: imranismail/setup-kustomize@v2
with:
kustomize-version: ${{ inputs.kustomize_version }}
- name: Patch image tag(s) (per app)
if: ${{ inputs.image_tag != '' }}
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: Run kustomize build (concat per app)
id: kustomize
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()
uses: actions/upload-artifact@v4
with:
name: built-kustomize-manifest-${{ steps.env.outputs.cluster }}
path: build-output.yaml
- name: Commit and push to continuous-deployment repo
run: |
set -euo pipefail
git -C continuous-deployment config user.name "k8s-deploy"
git -C continuous-deployment config user.email "actions@gitopsmanager.com"
echo "Adding files from: ${{ steps.copy.outputs.cd_path_rel }}/"
git -C continuous-deployment ls -la "${{ steps.copy.outputs.cd_path_rel }}/" || echo "⚠️ Directory not found"
echo "Before staging:"
git -C continuous-deployment status --short
git -C continuous-deployment add -A "${{ steps.copy.outputs.cd_path_rel }}/"
echo "After staging:"
git -C continuous-deployment status --short
git -C continuous-deployment diff --cached --stat || true
if git -C continuous-deployment diff --cached --quiet; then
echo "✅ No changes to commit."
exit 0
fi
git -C continuous-deployment commit -m "Deploy to ${{ steps.env.outputs.cluster }}/${{ inputs.namespace }} (apps: ${{ steps.apps.outputs.count }})"
git -C continuous-deployment push
- 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: 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}`;
let curlSslFlags = "";
if (String(process.env.INSECURE_ARGO) === "true") curlSslFlags = "-k";
else if (process.env.ARGOCD_CA_CERT) { fs.writeFileSync('/tmp/argocd-ca.crt', process.env.ARGOCD_CA_CERT); curlSslFlags = "--cacert /tmp/argocd-ca.crt"; }
let finalToken = process.env.TOKEN;
if (process.env.MODE !== "token") {
const body = JSON.stringify({ username: process.env.USERNAME, password: process.env.PASSWORD });
const resp = execSync(`curl -s ${curlSslFlags} -X POST "${argocdUrl}/api/v1/session" -H "Content-Type: application/json" -d '${body.replace(/'/g,"'\\''")}'`).toString();
try { finalToken = JSON.parse(resp).token; } catch { core.setFailed("❌ Failed to parse ArgoCD session response"); return; }
}
if (!finalToken) { core.setFailed("❌ Failed to obtain ArgoCD token."); return; }
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 }}
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 }}
NAMESPACE: ${{ inputs.namespace }}
with:
script: |
const apps = JSON.parse(process.env.APPS || '[]');
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 delRes = await fetch(appUrl, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${process.env.ARGOCD_TOKEN}`,
'Content-Type': 'application/json'
}
});
const delText = await delRes.text();
core.info(`Delete response [${delRes.status}]: ${delText}`);
if (![200, 202, 204].includes(delRes.status)) {
core.setFailed(`❌ Failed initial delete for ${appName}: HTTP ${delRes.status}`);
return;
}
// Wait until we see 403 = gone
const timeout = Date.now() + 120000; // 2 minutes
while (Date.now() < timeout) {
const res = await fetch(appUrl, {
headers: { Authorization: `Bearer ${process.env.ARGOCD_TOKEN}` }
});
if (res.status === 403) {
core.info(`✅ Application ${appName} fully deleted (HTTP 403)`);
break;
}
core.info(`⏳ Still deleting ${appName} (HTTP ${res.status})...`);
await new Promise(r => setTimeout(r, 5000));
}
}
- name: Check or create ArgoCD applications (per app)
uses: actions/github-script@v7
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 }}
NAMESPACE: ${{ inputs.namespace }}
with:
script: |
const fs = require('fs');
const path = require('path');
const nunjucks = require('./nunjucks.js');
async function deleteApp(appName) {
const appUrl = `${process.env.ARGOCD_URL}/api/v1/applications/${appName}`;
core.info(`🗑️ Deleting ArgoCD Application: ${appName}`);
const delRes = await fetch(appUrl, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${process.env.ARGOCD_TOKEN}`,
'Content-Type': 'application/json'
}
});
const delText = await delRes.text();
core.info(`Delete response [${delRes.status}]: ${delText}`);
if (![200, 202, 204].includes(delRes.status)) {
core.setFailed(`❌ Failed initial delete for ${appName}: HTTP ${delRes.status}`);
return false;
}
// Wait until we see 403 = gone
const timeout = Date.now() + 120000; // 2 minutes
while (Date.now() < timeout) {
const res = await fetch(appUrl, {
headers: { Authorization: `Bearer ${process.env.ARGOCD_TOKEN}` }
});
if (res.status === 403) {
core.info(`✅ Application ${appName} fully deleted (HTTP 403)`);
return true;
}
core.info(`⏳ Still deleting ${appName} (HTTP ${res.status})...`);
await new Promise(r => setTimeout(r, 5000));
}
core.setFailed(`❌ Timeout waiting for ${appName} to delete`);
return false;
}
const apps = JSON.parse(process.env.APPS || '[]');
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 res = await fetch(statusUrl, {
headers: { Authorization: `Bearer ${process.env.ARGOCD_TOKEN}` }
});
if (res.status === 200) {
const existing = await res.json();
const currentPath = existing?.spec?.source?.path;
if (currentPath !== basePath) {
core.warning(`⚠️ ${appName} path mismatch (current=${currentPath}, expected=${basePath}). Will delete and recreate.`);
const deleted = await deleteApp(appName);
if (!deleted) return;
} else {
core.info(`✅ Argo app ${appName} exists with correct path (${basePath}).`);
continue;
}
} else if (res.status !== 403) {
core.setFailed(`❌ Unexpected ArgoCD response for ${appName}: HTTP ${res.status}`);
return;
}
// Render JSON with Nunjucks template
const templatePath = 'reusable/templates/argocd-app-template-v5.json';
const template = fs.readFileSync(templatePath, '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
});
core.startGroup(`Rendered ArgoCD app spec for ${appName}`);
core.info(rendered);
core.endGroup();
const createRes = await fetch(`${process.env.ARGOCD_URL}/api/v1/applications`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ARGOCD_TOKEN}`,
'Content-Type': 'application/json'
},
body: rendered
});
if (!createRes.ok) {
const text = await createRes.text();
core.setFailed(`❌ Failed to create Argo app ${appName}: ${createRes.status} ${text}`);
return;
}
core.info(`✅ Created Argo app ${appName}.`);
}
- name: Sync ArgoCD apps (per app)
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 }}
NAMESPACE: ${{ inputs.namespace }}
with:
script: |
const apps = JSON.parse(process.env.APPS || '[]');
for (const app of apps) {
const appName = `${process.env.NAMESPACE}-${app.name}`;
const syncUrl = `${process.env.ARGOCD_URL}/api/v1/applications/${appName}/sync`;
const res = await fetch(syncUrl, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ARGOCD_TOKEN}`,
'Content-Type': 'application/json'
},
body: '{}'
});
const text = await res.text();
core.info(`Sync response [${res.status}] for ${appName}: ${text}`);
if (![200, 201].includes(res.status)) {
core.setFailed(`❌ Failed to trigger sync for ${appName}: HTTP ${res.status}`);
return;
}
core.info(`🚀 Sync triggered: ${appName}`);
}
- name: Wait for ArgoCD sync (per app)
if: ${{ not(inputs.skip_status_check) }}
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 }}
NAMESPACE: ${{ inputs.namespace }}
with:
script: |
const apps = JSON.parse(process.env.APPS || '[]');
for (const app of apps) {
const appName = `${process.env.NAMESPACE}-${app.name}`;
const statusUrl = `${process.env.ARGOCD_URL}/api/v1/applications/${appName}`;
core.info(`⏳ Waiting for sync of ${appName}...`);
let finalStatus = null;
for (let i = 0; i < 12; i++) { // 12 * 10s = 2 minutes
const res = await fetch(statusUrl, {
headers: { Authorization: `Bearer ${process.env.ARGOCD_TOKEN}` }
});
if (!res.ok) {
core.info(`Attempt ${i+1}: got HTTP ${res.status}`);
} else {
const json = await res.json();
const syncStatus = json?.status?.sync?.status;
finalStatus = syncStatus;
core.info(`Attempt ${i+1}: syncStatus=${syncStatus}`);
if (syncStatus === 'Synced') {
core.info(`✅ ArgoCD sync completed for ${appName}.`);
break;
}
if (syncStatus === 'Error' || syncStatus === 'Failed') {
core.setFailed(`❌ ArgoCD sync failed for ${appName} (status=${syncStatus})`);
return;
}
}
await new Promise(r => setTimeout(r, 10000));
}
if (finalStatus !== 'Synced') {
if (finalStatus === 'OutOfSync' || finalStatus === 'Unknown') {
core.warning(`⚠️ ArgoCD sync for ${appName} is still pending (status=${finalStatus}).`);
} else {
core.setFailed(`❌ ArgoCD sync did not complete in time for ${appName} (last status=${finalStatus})`);
return;
}
}
}