@Library('shared-lib') _ /* ============================================================================ * HA ADD-ONS CI/CD PIPELINE * Repo : HomeAssistantAddOns * File : ci/Jenkinsfile * Autore: Martin * * Descrizione * ----------- * Pipeline che su ogni push su main rileva quali addon sono cambiati, * esegue lint, builda l'immagine Docker (amd64) e la pusha sul registry * privato. Al termine aggiorna repository.json con i metadati aggiornati * e committa il file con [skip ci] per evitare loop infiniti. * * Flusso * ------ * 1. Checkout * 2. Detect — git diff → sotto-directory addon modificate * 3. Lint — hadolint + yaml/json validate + shellcheck (parallel) * 4. Build — docker build --platform linux/amd64 + push (parallel) * 5. Publish — aggiorna repository.json e committa su main * 6. Summary — tabella risultati + notifica ntfy * * Trigger * ------- * Generic Webhook Trigger su push → refs/heads/main * Token : homeassistant-addons * Webhook : https://pipelines.mt-home.uk/generic-webhook-trigger/invoke?token=homeassistant-addons * * Credenziali Jenkins richieste * ------------------------------ * gitea-credentials Username/Password — token Gitea (checkout + push) * registry-credentials Username/Password — Docker registry * * ============================================================================ */ pipeline { agent any /* ------------------------------------------------------------------------- * PARAMETRI * Valori infrastrutturali come parametri: portabilità garantita, * nessuna informazione sensibile nel Jenkinsfile. * ----------------------------------------------------------------------- */ parameters { string( name: 'GITEA_USER', defaultValue: 'martin', description: 'Username Gitea del repository HomeAssistantAddOns' ) string( name: 'NTFY_TOPIC', defaultValue: 'jenkins-addons', description: 'Topic ntfy per notifiche build (es: jenkins-addons)' ) string( name: 'NTFY_URL', defaultValue: 'https://ntfy.mt-home.uk', description: 'URL base ntfy — lasciare vuoto per disabilitare le notifiche' ) booleanParam( name: 'BUILD_ALL', defaultValue: false, description: 'Forza build di TUTTI gli addon (ignora il diff)' ) } /* ------------------------------------------------------------------------- * TRIGGER * * ATTUALE: pollSCM — Jenkins interroga il repo ogni 2 minuti e parte * se ci sono nuovi commit su main. Nessun plugin aggiuntivo richiesto. * * UPGRADE CONSIGLIATO → Generic Webhook Trigger (risposta immediata): * 1. Manage Jenkins → Plugin Manager → "Generic Webhook Trigger" → Install * 2. Sostituire il blocco triggers con: * * triggers { * GenericTrigger( * genericVariables: [ * [key: 'GITEA_REF', value: '$.ref'], * [key: 'GITEA_BEFORE', value: '$.before'], * [key: 'GITEA_AFTER', value: '$.after'], * ], * causeString: 'Push Gitea su $GITEA_REF', * token: 'homeassistant-addons', * printContributedVariables: true, * regexpFilterText: '$GITEA_REF', * regexpFilterExpression: 'refs/heads/main' * ) * } * * 3. Creare webhook in Gitea → Settings → Webhooks: * URL: https://pipelines.mt-home.uk/generic-webhook-trigger/invoke?token=homeassistant-addons * ----------------------------------------------------------------------- */ triggers { pollSCM('H/2 * * * *') } environment { REGISTRY = 'registry.mt-home.uk' GITEA_BASE_URL = 'https://git.mt-home.uk' REGISTRY_CREDS = credentials('registry-credentials') GITEA_CREDS = credentials('gitea-credentials') } options { disableConcurrentBuilds() timestamps() timeout(time: 60, unit: 'MINUTES') ansiColor('xterm') } // ========================================================================= stages { /* --------------------------------------------------------------------- * STAGE 1 — Checkout * ------------------------------------------------------------------- */ stage('Checkout') { steps { checkout([ $class: 'GitSCM', branches: [[name: '*/main']], userRemoteConfigs: [[ url: "${env.GITEA_BASE_URL}/${params.GITEA_USER}/HomeAssistantAddOns.git", credentialsId: 'gitea-credentials' ]], // Fetch completo: serve per il diff tra commit consecutivi extensions: [ [$class: 'CloneOption', depth: 0, shallow: false, noTags: false] ] ]) script { env.GIT_COMMIT_SHA = sh(script: 'git rev-parse HEAD', returnStdout: true).trim() env.GIT_PREV_SHA = sh(script: 'git rev-parse HEAD~1', returnStdout: true).trim() echo "[INFO] HEAD : ${env.GIT_COMMIT_SHA}" echo "[INFO] HEAD~1 : ${env.GIT_PREV_SHA}" } } } /* --------------------------------------------------------------------- * STAGE 2 — Detect changed addons * * Un addon valido è una sotto-directory della root che contiene * config.yaml oppure config.json (standard HA Supervisor). * Quando il trigger è il webhook usa i SHA before/after iniettati * dal Generic Webhook Trigger; altrimenti HEAD~1..HEAD. * ------------------------------------------------------------------- */ stage('Detect') { steps { script { // Tutti gli addon presenti nel workspace (indipendentemente da cosa è cambiato) def allAddons = sh( script: ''' find . -maxdepth 2 \\( -name 'config.yaml' -o -name 'config.json' \\) \ | sed 's|^./||' \ | xargs -I{} dirname {} \ | grep -v '^\\.\\?$' \ | grep -v '/' \ | sort -u ''', returnStdout: true ).trim().split('\n').findAll { it?.trim() } as List echo "[INFO] Addon disponibili nel repo: ${allAddons}" List toProcess if (params.BUILD_ALL) { toProcess = allAddons echo "[INFO] BUILD_ALL=true — selezionati tutti gli addon: ${toProcess}" } else { // SHA prima/dopo del push (da webhook o da git) def beforeSha = env.GITEA_BEFORE?.trim() ?: env.GIT_PREV_SHA def afterSha = env.GITEA_AFTER?.trim() ?: env.GIT_COMMIT_SHA // Diff: file modificati tra i due commit def changedFiles = sh( script: "git diff --name-only ${beforeSha} ${afterSha} 2>/dev/null || git diff --name-only HEAD~1 HEAD", returnStdout: true ).trim().split('\n').findAll { it?.trim() } as List echo "[INFO] File modificati: ${changedFiles}" // Estrai la top-level directory e filtra solo addon validi toProcess = changedFiles .collect { it.split('/')[0] } .unique() .findAll { dir -> allAddons.contains(dir) } if (toProcess.isEmpty()) { echo "[INFO] Nessun addon modificato in questo push — skip build." } else { echo "[INFO] Addon da buildare: ${toProcess}" } } // Serializza come stringa CSV per i prossimi stage env.ADDONS_TO_BUILD = toProcess.join(',') } } } /* --------------------------------------------------------------------- * STAGE 3 — Lint (parallel) * * Eseguito in parallelo per ogni addon rilevato. Un fallimento * di lint marca lo stage UNSTABLE ma non interrompe gli altri addon. * * Checks: * - hadolint → Dockerfile best practices * - yaml/json → config.yaml o config.json valido * - shellcheck → tutti gli *.sh nella directory * ------------------------------------------------------------------- */ stage('Lint') { when { expression { env.ADDONS_TO_BUILD?.trim() } } steps { script { def addons = env.ADDONS_TO_BUILD.split(',').findAll { it?.trim() } as List def lintOk = [] as List def lintWarn = [] as List def lintJobs = addons.collectEntries { addon -> [(addon): { stage("lint ❯ ${addon}") { try { // ---- Dockerfile ---- sh "docker run --rm -i hadolint/hadolint < ${addon}/Dockerfile" // ---- Config (yaml o json) ---- def configYaml = "${addon}/config.yaml" def configJson = "${addon}/config.json" sh """ if [ -f ${configYaml} ]; then python3 -c "import yaml,sys; yaml.safe_load(open(sys.argv[1]))" ${configYaml} echo "[OK] ${addon}/config.yaml valido" elif [ -f ${configJson} ]; then python3 -c "import json,sys; json.load(open(sys.argv[1]))" ${configJson} echo "[OK] ${addon}/config.json valido" fi """ // ---- Shell scripts ---- sh """ find ${addon}/ -name '*.sh' | while read f; do echo "[INFO] shellcheck: \$f" docker run --rm -v \$PWD:/mnt koalaman/shellcheck:stable /mnt/\$f done """ echo "[OK] ${addon}: tutti i check superati" lintOk.add(addon) } catch (err) { echo "[WARN] ${addon}: lint con avvisi — ${err.message}" lintWarn.add(addon) unstable("Lint warning in ${addon}") } } }] } parallel lintJobs // Serializza risultati lint per il summary def lintResultsMap = [:] lintOk.each { lintResultsMap[it] = 'OK' } lintWarn.each { lintResultsMap[it] = 'WARN' } env.LINT_RESULTS = lintResultsMap.collect { k, v -> "${k}:${v}" }.join(',') } } } /* --------------------------------------------------------------------- * STAGE 4 — Build & Push (parallel) * * Per ogni addon: * 1. Legge la versione da config.yaml / config.json * 2. Legge build_from.amd64 e lo passa come --build-arg BUILD_FROM * (pattern standard dei Dockerfile HA con ARG BUILD_FROM) * 3. docker build --platform linux/amd64 * 4. Push di :{version} e :latest * * Un fallimento su un addon non blocca gli altri. * ------------------------------------------------------------------- */ stage('Build & Push') { when { expression { env.ADDONS_TO_BUILD?.trim() } } steps { script { def addons = env.ADDONS_TO_BUILD.split(',').findAll { it?.trim() } as List // Login al registry una sola volta prima dei build paralleli sh "echo \"\$REGISTRY_CREDS_PSW\" | docker login ${env.REGISTRY} -u \"\$REGISTRY_CREDS_USR\" --password-stdin" def buildResults = [:] // addon → [status, version] def buildJobs = addons.collectEntries { addon -> [(addon): { stage("build ❯ ${addon}") { try { // Legge versione e build_from dalla config (yaml o json) def metaScript = """ import yaml, json, os, sys addon = sys.argv[1] for name in ('config.yaml', 'config.json'): p = os.path.join(addon, name) if os.path.exists(p): with open(p) as f: cfg = yaml.safe_load(f) if name.endswith('.yaml') else json.load(f) version = str(cfg.get('version', 'latest')) build_from = (cfg.get('build_from') or {}).get('amd64', '') print(f'{version}|{build_from}') sys.exit(0) print('latest|') """ writeFile file: '/tmp/read_meta.py', text: metaScript def meta = sh(script: "python3 /tmp/read_meta.py ${addon}", returnStdout: true).trim() def parts = meta.split('\\|') def version = parts[0] def buildFrom = parts.size() > 1 ? parts[1] : '' def imageBase = "${env.REGISTRY}/hassio-addons/${addon}" def buildArgLine = buildFrom ? "--build-arg BUILD_FROM=${buildFrom}" : '' def labelSource = "${env.GITEA_BASE_URL}/${params.GITEA_USER}/HomeAssistantAddOns" echo "[INFO] ${addon}: versione=${version}, BUILD_FROM=${buildFrom ?: '(none)'}" sh """ docker build \\ --platform linux/amd64 \\ ${buildArgLine} \\ --label "org.opencontainers.image.revision=${env.GIT_COMMIT_SHA}" \\ --label "org.opencontainers.image.source=${labelSource}" \\ --label "org.opencontainers.image.version=${version}" \\ -t ${imageBase}:${version} \\ -t ${imageBase}:latest \\ ${addon}/ """ sh "docker push ${imageBase}:${version}" sh "docker push ${imageBase}:latest" echo "[OK] ${addon}:${version} pushato su ${imageBase}" buildResults[addon] = [status: 'OK', version: version] } catch (err) { echo "[FAIL] Build di '${addon}' fallita: ${err.message}" buildResults[addon] = [status: 'FAILED', version: '-'] unstable("Build fallita: ${addon}") } } }] } parallel buildJobs // Serializza per i prossimi stage env.BUILD_RESULTS = buildResults.collect { k, v -> "${k}:${v.status}:${v.version}" }.join(',') } } } /* --------------------------------------------------------------------- * STAGE 5 — Publish (aggiorna repository.json) * * Eseguito solo se almeno un addon è stato buildato con successo. * Lo script Python: * - Legge/crea repository.json * - Upserta ogni addon buildato con successo (match su slug) * - Scrive il file aggiornato * Il commit usa [skip ci] nel messaggio per evitare loop webhook. * ------------------------------------------------------------------- */ stage('Publish') { when { expression { env.BUILD_RESULTS?.split(',')?.any { it?.contains(':OK:') } } } steps { script { def successAddons = env.BUILD_RESULTS.split(',') .findAll { it?.contains(':OK:') } .collect { it.split(':')[0] } echo "[INFO] Aggiorno repository.json per: ${successAddons}" def updateScript = '''\ import json, os, sys # Carica yaml solo se disponibile (config.yaml), altrimenti json try: import yaml HAS_YAML = True except ImportError: HAS_YAML = False REGISTRY = os.environ['REGISTRY'] GITEA_URL = os.environ['GITEA_BASE_URL'] GITEA_USER = sys.argv[1] addons_arg = sys.argv[2:] repo_file = 'repository.json' skeleton = { 'name': f'HA Add-ons by {GITEA_USER}', 'url': f'{GITEA_URL}/{GITEA_USER}/HomeAssistantAddOns', 'maintainer': GITEA_USER, 'addons': [] } repo = skeleton.copy() if os.path.exists(repo_file): with open(repo_file) as f: repo = json.load(f) repo.setdefault('addons', []) def load_cfg(addon_dir): for name, loader in [('config.yaml', 'yaml'), ('config.json', 'json')]: p = os.path.join(addon_dir, name) if not os.path.exists(p): continue with open(p) as f: if loader == 'yaml' and HAS_YAML: return yaml.safe_load(f) return json.load(f) return {} changed = False for addon in addons_arg: cfg = load_cfg(addon) if not cfg: print(f'[WARN] Nessuna config trovata per {addon}, skip', flush=True) continue slug = cfg.get('slug', addon) version = str(cfg.get('version', 'latest')) entry = { 'slug': slug, 'name': cfg.get('name', addon), 'description': cfg.get('description', ''), 'version': version, 'url': cfg.get('url', ''), 'arch': cfg.get('arch', ['amd64']), 'image': f'{REGISTRY}/hassio-addons/{slug}:{version}', } idx = next((i for i, a in enumerate(repo['addons']) if a.get('slug') == slug), None) if idx is not None: old_ver = repo['addons'][idx].get('version', '?') repo['addons'][idx] = entry print(f'[UPDATE] {slug}: {old_ver} → {version}', flush=True) else: repo['addons'].append(entry) print(f'[ADD] {slug} v{version}', flush=True) changed = True if changed: with open(repo_file, 'w') as f: json.dump(repo, f, indent=2, ensure_ascii=False) f.write('\\n') print('[OK] repository.json aggiornato', flush=True) else: print('[INFO] Nessuna modifica a repository.json', flush=True) ''' writeFile file: '/tmp/update_repo.py', text: updateScript sh "python3 /tmp/update_repo.py \"${params.GITEA_USER}\" ${successAddons.join(' ')}" sh 'git diff repository.json || true' // Commit e push solo se ci sono modifiche staged withEnv(["GITEA_USER=${params.GITEA_USER}"]) { sh ''' git config user.email "jenkins@pipelines.mt-home.uk" git config user.name "Jenkins CI" git add repository.json if git diff --staged --quiet; then echo "[INFO] Nessuna modifica a repository.json da committare" else git commit -m "chore: update repository.json [skip ci]" git push \ "https://oauth2:${GITEA_CREDS_PSW}@git.mt-home.uk/${GITEA_USER}/HomeAssistantAddOns.git" \ HEAD:main echo "[OK] repository.json pushato su main" fi ''' } } } } } // end stages // ========================================================================= /* ------------------------------------------------------------------------- * POST ACTIONS * ----------------------------------------------------------------------- */ post { always { script { // Ricostruisce le mappe risultato dalla serializzazione CSV def lintMap = env.LINT_RESULTS ? env.LINT_RESULTS.split(',').collectEntries { def p = it.split(':'); [(p[0]): p.size() > 1 ? p[1] : '-'] } : [:] def buildMap = env.BUILD_RESULTS ? env.BUILD_RESULTS.split(',').collectEntries { def p = it.split(':') [(p[0]): [status: p.size() > 1 ? p[1] : '-', version: p.size() > 2 ? p[2] : '-']] } : [:] // Tabella riepilogativa def addons = env.ADDONS_TO_BUILD?.trim() ? env.ADDONS_TO_BUILD.split(',') as List : [] def header = String.format('%-26s %-6s %-8s %-12s', 'Addon', 'Lint', 'Build', 'Version') def divider = '─' * 56 def rows = addons.collect { addon -> String.format('%-26s %-6s %-8s %-12s', addon, lintMap[addon] ?: '─', buildMap[addon]?.status ?: '─', buildMap[addon]?.version ?: '─' ) } echo "\n${divider}\n${header}\n${divider}\n${rows.join('\n')}\n${divider}" if (addons.isEmpty()) { echo '[INFO] Nessun addon processato in questo build.' } // Notifica ntfy def anyFailed = buildMap.any { k, v -> v.status == 'FAILED' } def summary = addons.isEmpty() ? "Build #${env.BUILD_NUMBER}: nessun addon da buildare" : anyFailed ? "Build #${env.BUILD_NUMBER}: uno o più addon falliti" : "Build #${env.BUILD_NUMBER}: ${buildMap.size()} addon completati ✓" if (params.NTFY_URL?.trim()) { try { sh """ curl -sf \\ -H "Title: HA Add-ons CI" \\ -H "Priority: ${anyFailed ? 'high' : 'default'}" \\ -H "Tags: ${anyFailed ? 'warning' : 'white_check_mark'}" \\ -d "${summary}" \\ "${params.NTFY_URL}/${params.NTFY_TOPIC}" || true """ } catch (e) { echo "[WARN] ntfy non raggiungibile: ${e.message}" } } // Notifica via shared-lib pipelineEvent (se disponibile) try { pipelineEvent( title: 'HA Add-ons CI', context: 'Build & Push', status: currentBuild.result ?: 'SUCCESS', ) } catch (e) { echo "[WARN] pipelineEvent fallito: ${e.message}" } } } success { echo '[SUCCESS] ✓ Pipeline completata con successo.' } unstable { echo '[WARN] ⚠ Uno o più step con avvisi — verificare i log.' } failure { echo '[ERROR] ✗ Pipeline fallita.' } cleanup { sh 'docker logout ${REGISTRY} 2>/dev/null || true' sh 'rm -f /tmp/read_meta.py /tmp/update_repo.py' } } }