Files
HomeAssistantAddons/ci/Jenkinsfile
2026-04-02 14:52:04 +02:00

449 lines
20 KiB
Groovy
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@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
* ------------------------------
* GIT_TOKEN Username/Password — token Gitea (checkout + push)
* REGISTRY 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'
// Credenziali NON vincolate qui — usare withCredentials() dentro gli stage.
// Vincolare credentials() a livello di pipeline causa un abort immediato
// se la credenziale non esiste, prima ancora che giri qualsiasi stage.
}
options {
disableConcurrentBuilds()
timestamps()
timeout(time: 60, unit: 'MINUTES')
ansiColor('xterm')
}
// =========================================================================
stages {
/* ---------------------------------------------------------------------
* STAGE 1 — Detect changed addons
*
* Il checkout è già fatto da "Declarative: Checkout SCM" automatico.
* Un addon valido è una sotto-directory della root che contiene
* config.yaml oppure config.json (standard HA Supervisor).
* ------------------------------------------------------------------- */
stage('Detect') {
steps {
script {
// SHA del commit corrente e del precedente (già nel workspace)
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}"
// 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<String> 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 {
sh "bash ci/scripts/lint_addon.sh ${addon}"
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 dentro withCredentials — non fallisce il
// pipeline se la credenziale non esiste ancora (gestisce errore)
withCredentials([usernamePassword(
credentialsId: 'REGISTRY',
usernameVariable: 'REGISTRY_USR',
passwordVariable: 'REGISTRY_PSW'
)]) {
sh 'echo "$REGISTRY_PSW" | docker login ${REGISTRY} -u "$REGISTRY_USR" --password-stdin'
}
def buildResults = [:] // addon → [status, version]
def buildJobs = addons.collectEntries { addon ->
[(addon): {
stage("build ${addon}") {
try {
def meta = sh(script: "python3 ci/scripts/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}"
sh "python3 ci/scripts/update_repo.py \"${params.GITEA_USER}\" ${successAddons.join(' ')}"
sh 'git diff repository.json || true'
withCredentials([usernamePassword(
credentialsId: 'GIT_TOKEN',
usernameVariable: 'GITEA_PUSH_USR',
passwordVariable: 'GITEA_PUSH_PSW'
)]) {
sh "bash ci/scripts/git_push_repo.sh \"${params.GITEA_USER}\" \"${env.GITEA_BASE_URL}\""
}
}
}
}
} // 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 {
script {
try {
sh 'docker logout ${REGISTRY} 2>/dev/null || true'
} catch (e) {
echo "[WARN] cleanup: ${e.message}"
}
}
}
}
}