From 90793f500be780281d9122ce05b0a36ecb5bddd9 Mon Sep 17 00:00:00 2001 From: Martin Tahiraj Date: Thu, 2 Apr 2026 12:05:55 +0200 Subject: [PATCH] feat(jenkins): add CI/CD pipeline for HA add-ons with linting, building, and publishing --- ci/Jenkinsfile | 573 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 573 insertions(+) create mode 100644 ci/Jenkinsfile diff --git a/ci/Jenkinsfile b/ci/Jenkinsfile new file mode 100644 index 0000000..1534c2d --- /dev/null +++ b/ci/Jenkinsfile @@ -0,0 +1,573 @@ +@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: 'martemme', + 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 + * Il plugin Generic Webhook Trigger deve essere installato in Jenkins. + * Se non presente: Manage Jenkins → Plugin Manager → cerca "Generic Webhook Trigger". + * ----------------------------------------------------------------------- */ + 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' + ) + } + + 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 { _, 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' + } + } + +}