name: 'Build, scan & push' description: >- Construit une image avec buildah, sans démon ni socket, la passe au scan de vulnérabilités BLOQUANT, et — sur develop ou un tag vX.Y.Z seulement — la publie dans le registre de Gitea (ADR 0002 et 0003 de RF_CICD). Ne déploie rien : la filière dev tire l'image, la production se déploie sur ordre. # Sur TOUTE référence — PR comprise — l'image est construite et scannée : une # CVE HIGH ou CRITICAL corrigeable fait échouer le job, avant toute revue de # fusion (RF_CICD#7, demande de Benjamin du 2026-10-08 : « le scan doit se faire # au build pour empêcher le build »). Ensuite seulement, selon la référence : # # poussée sur develop → publie :dev et : # tag vX.Y.Z → publie :vX.Y.Z, orthographié comme le tag git # toute autre référence → ne publie RIEN : construction et scan seulement # # Une PR, une branche de fonctionnalité, une poussée sur main (ADR 0002) ne # publient donc jamais : elles ne peuvent ni écraser :dev, ni produire une image # sous un nom que personne n'a choisi. Et elles n'ont pas besoin des # identifiants du registre — l'action ne les demande que pour publier. # # Il n'y a volontairement aucun tag flottant de production (pas de :prod, pas # de :latest), et rien n'est élagué. inputs: image: description: >- Nom complet de l'image, sans tag — doit commencer par le registre, ex. gitea.raggaroth-factory.fr/raggaroth-factory/scrum-board-backend required: true dockerfile: description: Chemin du Dockerfile/Containerfile à construire. required: true context: description: Contexte de construction. required: false default: '.' registry: description: Hôte du registre. required: false default: gitea.raggaroth-factory.fr registry_user: description: Compte qui publie dans le registre. Exigé seulement quand la référence publie. required: false default: '' registry_password: description: >- Jeton du compte qui publie. Par un secret Gitea Actions, jamais en clair. Exigé seulement quand la référence publie. required: false default: '' outputs: tags: description: Les tags publiés, séparés par des espaces (vide si rien n'est publié). value: ${{ steps.publish.outputs.tags }} publie: description: « true » si l'image a été publiée, « false » si elle a seulement été construite et scannée. value: ${{ steps.tags.outputs.publier }} digest: description: L'empreinte (sha256:…) de l'image publiée — c'est elle que la filière dev compare. value: ${{ steps.publish.outputs.digest }} runs: using: composite steps: # Les entrées passent TOUJOURS par l'environnement, jamais par « ${{ }} » # dans le script : une expression est recopiée telle quelle dans le texte # bash avant son exécution, et une valeur contenant « "; … » deviendrait du # code. L'ancienne version interpolait ainsi le mot de passe du registre # dans un « echo ». - name: Tags de la filière id: tags shell: bash env: INPUT_IMAGE: ${{ inputs.image }} INPUT_REGISTRY: ${{ inputs.registry }} run: | set -euo pipefail case "$INPUT_IMAGE" in "$INPUT_REGISTRY"/*) ;; *) echo "::error::l'image « $INPUT_IMAGE » n'est pas dans le registre « $INPUT_REGISTRY » : les identifiants seraient envoyés au mauvais hôte, ou l'image poussée sans eux" exit 1 ;; esac # GITHUB_REF et non GITHUB_REF_NAME : seul le premier distingue une # branche « v1 » d'un tag « v1 ». PUBLIER=true case "$GITHUB_REF" in refs/heads/develop) TAGS="dev ${GITHUB_SHA:0:12}" ;; refs/tags/v*) VERSION=${GITHUB_REF#refs/tags/} # Un tag d'image n'accepte que [A-Za-z0-9_.-] : on exige une # version lisible plutôt que de corriger en silence ce qui a été # posé, pour que le tag git et le tag d'image restent identiques. if [[ ! "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then echo "::error::tag « $VERSION » : attendu vX.Y.Z (ou vX.Y.Z-rc.1)" exit 1 fi TAGS="$VERSION" ;; *) # Un tag LOCAL, jamais poussé : il ne sert qu'à nommer l'image le # temps de la scanner. TAGS="controle-${GITHUB_SHA:0:12}"; PUBLIER=false ;; esac echo "tags=$TAGS" >> "$GITHUB_OUTPUT" echo "publier=$PUBLIER" >> "$GITHUB_OUTPUT" if [ "$PUBLIER" = true ]; then echo "« $GITHUB_REF » : construction, scan, puis publication de : $TAGS" else echo "« $GITHUB_REF » : construction et scan seulement — cette référence ne publie rien (ADR 0002)" fi - name: Construire, scanner, publier id: publish shell: bash env: INPUT_IMAGE: ${{ inputs.image }} INPUT_DOCKERFILE: ${{ inputs.dockerfile }} INPUT_CONTEXT: ${{ inputs.context }} INPUT_REGISTRY: ${{ inputs.registry }} INPUT_TAGS: ${{ steps.tags.outputs.tags }} PUBLIER: ${{ steps.tags.outputs.publier }} REGISTRY_USER: ${{ inputs.registry_user }} REGISTRY_PASSWORD: ${{ inputs.registry_password }} run: | set -euo pipefail for OUTIL in buildah trivy; do if ! command -v "$OUTIL" >/dev/null; then echo "::error::$OUTIL absent de l'image de job : le runner doit exécuter ce job dans une image qui le porte (voir README de RF_ACTIONS)" exit 1 fi done # « --storage-driver vfs » sur CHAQUE appel, et pas par la variable # STORAGE_DRIVER : constaté le 2026-10-07 sur buildah 1.33.7, avec la # seule variable, un « build » a laissé un stockage overlay que l'appel # suivant a refusé d'ouvrir (« 'overlay' is not supported over # overlayfs »). Avec le drapeau explicite, build, images et push # passent. vfs est le seul pilote qui tienne # dans un conteneur de job sans privilège ni /dev/fuse (ADR 0003). b() { buildah --storage-driver vfs "$@"; } # Fichier d'authentification explicite, dans le répertoire temporaire du # job, et effacé en sortie quoi qu'il arrive. Le conteneur est jeté à la # fin du job, mais un jeton n'a pas à lui survivre d'une étape. FICHIER_AUTH="${RUNNER_TEMP:-/tmp}/build-push-auth.json" IMAGE_OCI="${RUNNER_TEMP:-/tmp}/build-push-image-oci" trap 'rm -rf -- "$FICHIER_AUTH" "$IMAGE_OCI"' EXIT # Connexion au registre SEULEMENT si la référence publie : une PR n'a # pas à manipuler le jeton de publication, ni même à le recevoir. # printf est une primitive du shell : le jeton ne passe dans l'argv # d'aucun processus, donc pas dans /proc//cmdline. # REGISTRY_AUTH_FILE n'est exporté QUE si l'on publie : pointé sur un # fichier inexistant, il fait échouer buildah jusque dans l'export local # de l'image vers le scan (constaté le 2026-10-08, « credential file is # not accessible »). if [ "$PUBLIER" = true ]; then export REGISTRY_AUTH_FILE="$FICHIER_AUTH" if [ -z "$REGISTRY_USER" ] || [ -z "$REGISTRY_PASSWORD" ]; then echo "::error::cette référence publie, mais registry_user / registry_password sont vides — secrets REGISTRY_USER / REGISTRY_PASSWORD du dépôt ?" exit 1 fi printf '%s' "$REGISTRY_PASSWORD" \ | b login --username "$REGISTRY_USER" --password-stdin "$INPUT_REGISTRY" fi TAG_ARGS=() for TAG in $INPUT_TAGS; do TAG_ARGS+=(--tag "$INPUT_IMAGE:$TAG"); done # --isolation chroot : pas d'espace de noms imbriqué à créer pour les # RUN, c'est ce qui permet de construire sans privilège (ADR 0003). # Les étiquettes OCI disent de quel commit vient l'image : c'est ce que # lira un « deployer.sh --constat » pour dire ce qui tourne. b build \ --isolation chroot \ --file "$INPUT_DOCKERFILE" \ --label "org.opencontainers.image.revision=$GITHUB_SHA" \ --label "org.opencontainers.image.source=$GITHUB_SERVER_URL/$GITHUB_REPOSITORY" \ "${TAG_ARGS[@]}" \ "$INPUT_CONTEXT" # ── Scan de vulnérabilités, BLOQUANT, avant toute publication ────────── # Seuil décidé le 2026-10-07 (RF_CICD#7) : une CVE HIGH ou CRITICAL # pour laquelle un correctif est publié fait échouer le job, comme un # test rouge. Le reste — dont les CVE sans correctif, contre lesquelles # personne ne peut rien — est rapporté sans bloquer. Le seuil n'est PAS # une entrée de l'action : un dépôt ne peut pas l'abaisser pour lui seul. # Une CVE acceptée sciemment va dans .trivyignore.yaml, à la racine du # dépôt appelant, avec une justification et une date d'expiration. # # Trivy lit l'image construite sous forme de RÉPERTOIRE OCI : il n'y a # pas de démon à interroger, et rien n'est publié avant son verdict. # Un répertoire et non une archive : « --input » n'accepte que l'archive # au format Docker (manifest.json) ou le répertoire OCI (index.json) — # une archive OCI est refusée (constaté, trivy 0.75.0). PREMIER_TAG=${INPUT_TAGS%% *} b push "$INPUT_IMAGE:$PREMIER_TAG" "oci:$IMAGE_OCI" IGNORE=() FICHIER_IGNORE="${GITHUB_WORKSPACE:-.}/.trivyignore.yaml" if [ -f "$FICHIER_IGNORE" ]; then # Chaque exception porte un « statement » (pourquoi) et un # « expired_at » (jusqu'à quand) : à l'échéance, Trivy cesse de # l'appliquer et la CVE bloque de nouveau. Une exception sans date # serait une CVE acceptée pour toujours, sans que personne l'ait dit. if ! awk ' /^[[:space:]]*-[[:space:]]*id:/ { n++; id[n]=$0 } /^[[:space:]]*statement:/ { s[n]=1 } /^[[:space:]]*expired_at:/ { e[n]=1 } END { ok=1; for (i=1;i<=n;i++) if (!s[i] || !e[i]) { print " incomplète :" id[i]; ok=0 } exit !ok } ' "$FICHIER_IGNORE"; then echo "::error::.trivyignore.yaml : chaque exception doit porter « statement » et « expired_at »" exit 1 fi IGNORE=(--ignorefile "$FICHIER_IGNORE") echo "Exceptions lues dans .trivyignore.yaml" fi # Rapport complet des HIGH/CRITICAL, corrigeables ou non — pour voir, # pas pour juger. Puis le verdict, sur les seules corrigeables. echo "::group::Vulnérabilités HIGH et CRITICAL, corrigeables ou non (rapport)" trivy image --quiet --scanners vuln --severity HIGH,CRITICAL \ "${IGNORE[@]}" --format table --exit-code 0 --input "$IMAGE_OCI" echo "::endgroup::" if ! trivy image --quiet --skip-db-update --scanners vuln --severity HIGH,CRITICAL \ --ignore-unfixed "${IGNORE[@]}" --format table --exit-code 1 --input "$IMAGE_OCI"; then echo "::error::CVE HIGH ou CRITICAL corrigeable dans l'image : elle est REFUSÉE, et rien n'est publié. Corriger (monter l'empreinte de la base, ou la version du paquet), ou l'accepter sciemment dans .trivyignore.yaml, avec justification et date d'expiration." exit 1 fi echo "Scan : aucune CVE HIGH ou CRITICAL corrigeable — image acceptée" if [ "$PUBLIER" != true ]; then echo "tags=" >> "$GITHUB_OUTPUT" echo "Construction et scan réussis — « $GITHUB_REF » ne publie rien." exit 0 fi DIGEST_FILE="${RUNNER_TEMP:-/tmp}/build-push-digest" for TAG in $INPUT_TAGS; do b push --digestfile "$DIGEST_FILE" "$INPUT_IMAGE:$TAG" "docker://$INPUT_IMAGE:$TAG" echo "Publié : $INPUT_IMAGE:$TAG@$(cat "$DIGEST_FILE")" done echo "digest=$(cat "$DIGEST_FILE")" >> "$GITHUB_OUTPUT" echo "tags=$INPUT_TAGS" >> "$GITHUB_OUTPUT" b logout "$INPUT_REGISTRY" || true