Aller au contenu
Construire en intégration continue

Construire en intégration continue

À la fin, vous saurez

  • Écrire un workflow GitHub Actions qui teste, construit, atteste, analyse, signe et publie une image
  • Restreindre les droits du workflow et protéger les secrets, y compris face aux contributions externes
  • Partager le cache de construction entre exécutions de CI
  • Valider un workflow avant de le pousser
  • Évaluer les constructions sans démon privilégié et leurs limites

Prérequis

Testé avec actionlint 1.7.12 buildx 0.37.1 cosign 3.1.3 docker 29.8.1 trivy 0.74.0 , vérifié le 1 octobre 2026

Pourquoi

Chacune des neuf leçons précédentes a ajouté une propriété à l'image de Signalements : une base choisie, des étapes séparées, un cache efficace, aucun secret, une image minimale, une construction reproductible, plusieurs architectures, un inventaire et une provenance, une signature. Sur un poste de développeur, toutes ces propriétés dépendent de la discipline de la personne qui tape les commandes. En production, elles doivent être garanties : chaque image publiée doit avoir été testée, analysée, attestée et signée, sans exception, et sans que personne n'ait eu à y penser.

C'est le rôle de l'intégration continue : un système qui exécute toujours la même chaîne, à partir d'un commit, dans un environnement propre, et qui est le seul à pouvoir publier. Le site que vous lisez est publié ainsi : chaque commit sur la branche principale du dépôt apprendre déclenche une construction, une image est poussée sur le registre Scaleway de Lyneko avec l'étiquette main-<commit>, et Argo CD la déploie.

Cette leçon assemble la chaîne complète pour Signalements dans un workflow GitHub Actions. Une précision honnête : un workflow GitHub Actions ne s'exécute que chez GitHub, et nous ne pouvons pas pousser vers le registre de production depuis ce cours. Le workflow a donc été validé avec actionlint, et chacune de ses étapes a été exécutée localement, avec les mêmes options, contre un registre local, sur des builders neufs qui jouent le rôle d'agents de CI.

Les concepts

La chaîne, étape par étape

    flowchart LR
  C["Commit"] --> S["Date de référence<br/>SOURCE_DATE_EPOCH"]
  S --> T["Tests<br/>--target test"]
  T --> B["Construction<br/>cache, SBOM, provenance"]
  B --> P["Publication<br/>par empreinte"]
  P --> A["Analyse Trivy<br/>bloquante"]
  A --> G["Signature<br/>sans clé"]
  G --> D["Déploiement<br/>(chapitre Livrer)"]
  
ÉtapeLeçonCe qu'elle garantit
Date de référence6Construction reproductible, vérifiable par un tiers
Tests2Aucune image publiée sans tests réussis, avec ses dépendances exactes
Construction avec cache3Durée maîtrisée sur des agents neufs
SBOM et provenance8Inventaire et origine joints à l'image
Analyse bloquante1 (et leçon 12 du cours précédent)Pas de vulnérabilité haute corrigeable non traitée
Signature9Preuve que l'image vient de cette chaîne, et d'aucune autre

Les droits du workflow

Un workflow manipule des choses sensibles : un accès en écriture au registre, une identité qui signe. Trois principes :

  • le jeton du workflow n'a que les droits nécessaires, déclarés explicitement (permissions:) ;
  • les secrets ne servent qu'aux exécutions de confiance : une demande de fusion (pull request) venant d'un dépôt dérivé (fork) ne doit ni publier, ni signer, ni voir les secrets ;
  • les actions tierces sont épinglées par empreinte de commit, pas par étiquette : une étiquette v4 peut être déplacée par le mainteneur, ou par un attaquant qui a compromis son compte, comme ce fut le cas en mars 2025 pour l'action populaire tj-actions/changed-files, dont les étiquettes ont été réécrites pour exfiltrer les secrets des workflows qui l'utilisaient.

En pratique

Le workflow complet

Le fichier .github/workflows/image.yml du dépôt Signalements :

name: Image Signalements

on:
  push:
    branches: [main]
  pull_request:

# Par défaut, le jeton du workflow ne peut que lire le dépôt.
permissions:
  contents: read

env:
  IMAGE: rg.fr-par.scw.cloud/lyneko-apps/signalements

jobs:
  image:
    runs-on: ubuntu-24.04
    permissions:
      contents: read
      id-token: write # jeton OIDC pour la signature sans clé
    steps:
      - name: Récupérer les sources
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

      - name: Date de référence (constructions reproductibles)
        run: echo "SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)" >> "$GITHUB_ENV"

      - name: Préparer Buildx
        uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1

      - name: Se connecter au registre Scaleway
        if: github.event_name == 'push'
        uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
        with:
          registry: rg.fr-par.scw.cloud
          username: nologin
          password: ${{ secrets.SCW_SECRET_KEY }}

      - name: Étiquettes et annotations
        id: meta
        uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
        with:
          images: ${{ env.IMAGE }}
          tags: |
            type=sha,prefix=main-,format=short,enable={{is_default_branch}}
            type=ref,event=pr
          # Date du commit plutôt que l'heure de la construction : l'image reste reproductible.
          labels: |
            org.opencontainers.image.created={{commit_date 'YYYY-MM-DDTHH:mm:ss.SSS[Z]'}}
          annotations: |
            org.opencontainers.image.created={{commit_date 'YYYY-MM-DDTHH:mm:ss.SSS[Z]'}}

      - name: Tests (étape « test » du Dockerfile)
        uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
        with:
          context: .
          target: test
          cache-from: type=registry,ref=${{ env.IMAGE }}:cache

      - name: Construire, attester et publier
        id: construction
        uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
        with:
          context: .
          platforms: linux/amd64
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          annotations: ${{ steps.meta.outputs.annotations }}
          build-args: SOURCE_DATE_EPOCH=${{ env.SOURCE_DATE_EPOCH }}
          outputs: type=image,push=${{ github.event_name == 'push' }},rewrite-timestamp=true
          sbom: true
          provenance: mode=max
          cache-from: type=registry,ref=${{ env.IMAGE }}:cache
          cache-to: ${{ github.event_name == 'push' && format('type=registry,ref={0}:cache,mode=max', env.IMAGE) || '' }}

      - name: Analyse de vulnérabilités (bloquante)
        if: github.event_name == 'push'
        uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
        with:
          image-ref: ${{ env.IMAGE }}@${{ steps.construction.outputs.digest }}
          severity: CRITICAL,HIGH
          ignore-unfixed: true
          exit-code: "1"
          version: v0.74.0

      - name: Installer Cosign
        if: github.event_name == 'push'
        uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
        with:
          cosign-release: v3.1.3

      - name: Signer l'image (sans clé, identité du workflow)
        if: github.event_name == 'push'
        env:
          EMPREINTE: ${{ steps.construction.outputs.digest }}
        run: cosign sign --yes "${IMAGE}@${EMPREINTE}"

Les choix, de haut en bas :

  • Déclencheurs. Un push sur main construit, publie et signe ; une pull_request construit et teste seulement. Toutes les étapes qui publient, analysent l'image publiée ou signent sont conditionnées par if: github.event_name == 'push' : une demande de fusion, même venant d'un fork, ne touche ni au registre ni aux secrets (GitHub ne fournit d'ailleurs pas les secrets aux workflows déclenchés depuis un fork).
  • Permissions. Au niveau du workflow, lecture seule. Le travail y ajoute id-token: write, qui autorise uniquement l'obtention d'un jeton OIDC attestant l'identité du workflow : c'est ce jeton que Cosign échange contre un certificat Fulcio (leçon 9). Aucun droit d'écriture sur le dépôt.
  • Actions épinglées par empreinte, avec la version en commentaire pour la lisibilité. Les empreintes ci-dessus sont celles des versions publiées au 1er octobre 2026 ; Dependabot ou Renovate les tiennent à jour.
  • SOURCE_DATE_EPOCH est calculée à partir du commit, écrite dans $GITHUB_ENV pour les étapes suivantes, passée en argument de construction, et rewrite-timestamp=true est ajouté à la sortie (leçon 6).
  • Connexion au registre Scaleway. Le registre attend l'utilisateur nologin et, comme mot de passe, la clé secrète d'une application IAM dédiée, dont la politique est limitée au registre (idéalement dans un projet Scaleway dédié aux images, puisque les droits s'accordent par projet). C'est le montage utilisé pour publier ce site.
  • Étiquettes. docker/metadata-action calcule main-<commit court> sur la branche principale (pr-<numéro> pour une demande de fusion) et produit les étiquettes et annotations OCI standard (org.opencontainers.image.source, revision, created...) à partir du contexte GitHub. Par défaut, created vaut l'heure de la construction : deux constructions du même commit auraient alors des configurations différentes, et la reproductibilité serait perdue. Les entrées labels et annotations la remplacent par la date du commit (expression {{commit_date ...}}). Vérifié localement : avec un label et une annotation created fixés à la date du commit, deux agents neufs produisent toujours le même manifeste (sha256:993970ea...).
  • Deux constructions, un cache. La première construit la cible test (leçon 2) : si un test échoue, le travail s'arrête. La seconde construit l'image finale, en réutilisant les couches communes, et exporte le cache en mode=max vers une étiquette cache du registre (leçon 3), seulement sur main, pour qu'une demande de fusion ne puisse pas empoisonner le cache de la branche principale.
  • Une seule plateforme. Signalements compile psycopg[c] (leçon 2) et ne se prête donc pas à la compilation croisée (leçon 7). Pour publier aussi linux/arm64, on ajouterait un second travail sur un agent ARM (runs-on: ubuntu-24.04-arm), puis une étape qui fusionne les deux manifestes dans un index (docker buildx imagetools create).
  • Analyse bloquante, puis signature. L'image est publiée par son empreinte, analysée, et signée seulement si l'analyse réussit. Une image vulnérable reste dans le registre, mais non signée : la politique d'admission du cluster (leçon 9) refuse de la déployer. La signature porte sur l'empreinte de l'index, qui référence par empreinte les manifestes d'attestation de BuildKit (SBOM et provenance) : elle les couvre donc aussi. Pour des attestations signées individuellement et vérifiables avec cosign verify-attestation, on ajouterait des étapes cosign attest (leçon 9).

Valider le workflow avant de le pousser

Un workflow ne s'exécute qu'une fois poussé, et une faute de frappe se découvre trop tard. actionlint vérifie la syntaxe, les expressions, les références entre étapes, les permissions, et analyse les scripts run: avec ShellCheck :

$ actionlint .github/workflows/image.yml && echo "actionlint : aucune erreur"
actionlint : aucune erreur

Introduisons deux erreurs réalistes, une valeur de permission invalide et une faute dans le nom d'une étape :

$ actionlint image-erreur.yml
image-erreur.yml:20:17: "ecrire" is invalid as permission of scope "id-token". available values are "write", "none" [permissions]
   |
20 |       id-token: ecrire
   |                 ^~~~~~
image-erreur.yml:75:43: property "constructon" is not defined in object type {construction: {conclusion: string; outcome: string; outputs: {string => string}}; meta: {conclusion: string; outcome: string; outputs: {string => string}}} [expression]
   |
75 |           image-ref: ${{ env.IMAGE }}@${{ steps.constructon.outputs.digest }}
   |                                           ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
...

La seconde erreur est exactement celle qui aurait fait analyser une image vide (IMAGE@ sans empreinte) sans que le workflow échoue clairement. actionlint a sa place dans les vérifications de la CI elle-même, ou dans un crochet de pré-commit.

Exécuter la chaîne localement

Chaque étape du workflow a été rejouée localement, contre un registre local, sur un builder docker-container neuf (agent1) qui joue le rôle d'un agent de CI. Le dépôt Git local a pour origine https://github.com/lyneko-team/signalements.git.

Date de référence et étiquette :

$ export IMAGE=127.0.0.1:5000/lyneko-apps/signalements
$ export SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)
$ TAG=main-$(git rev-parse --short=7 HEAD)
$ echo "SOURCE_DATE_EPOCH=$SOURCE_DATE_EPOCH TAG=$TAG"
SOURCE_DATE_EPOCH=1790860706 TAG=main-7f907ea

Tests, sur un agent sans aucun cache :

$ docker buildx build --builder agent1 --progress=plain --target test \
    --cache-from type=registry,ref=$IMAGE:cache .
...
#6 importing cache manifest from 127.0.0.1:5000/lyneko-apps/signalements:cache
#6 ERROR: failed to configure registry cache importer: 127.0.0.1:5000/lyneko-apps/signalements:cache: not found
...
#15 0.436 2 passed in 0.06s

Le cache n'existe pas encore : l'erreur d'import est signalée et la construction continue (leçon 3). Les tests passent.

Construction, attestations et publication :

$ /usr/bin/time -f "construction : %e s" docker buildx build --builder agent1 --progress=plain \
    --platform linux/amd64 -t $IMAGE:$TAG \
    --build-arg SOURCE_DATE_EPOCH --output type=image,push=true,rewrite-timestamp=true \
    --sbom=true --provenance=mode=max \
    --cache-from type=registry,ref=$IMAGE:cache --cache-to type=registry,ref=$IMAGE:cache,mode=max \
    --metadata-file meta.json .
...
#21 pushing manifest for 127.0.0.1:5000/lyneko-apps/signalements:main-7f907ea@sha256:cd7db6c48aa02c71d2cc991a3caf9dd309bb3b65bdea263894c0b8e53042c029
...
construction : 32.90 s
$ jq -r '."containerimage.digest"' meta.json
sha256:cd7db6c48aa02c71d2cc991a3caf9dd309bb3b65bdea263894c0b8e53042c029

Le fichier de métadonnées donne l'empreinte publiée : c'est ce que l'action build-push-action expose comme sortie digest, et ce qu'utilisent les étapes suivantes.

Analyse bloquante, sur l'image publiée, par son empreinte :

$ docker run --rm --network host -v trivy-cache:/root/.cache aquasec/trivy:0.74.0 image --quiet --insecure \
    --severity CRITICAL,HIGH --ignore-unfixed --exit-code 1 $IMAGE@sha256:cd7db6c4...
...
$ echo "code de sortie de l'analyse : $?"
code de sortie de l'analyse : 0

Aucune vulnérabilité haute ou critique corrigeable : code 0, la chaîne continue. Pour vérifier que la barrière fonctionne vraiment, la même analyse sur l'image python:3.14-slim d'origine, dont la leçon 1 a montré les correctifs manquants :

$ docker run --rm -v trivy-cache:/root/.cache aquasec/trivy:0.74.0 image --quiet \
    --severity CRITICAL,HIGH --ignore-unfixed --exit-code 1 python:3.14-slim
...
python:3.14-slim (debian 13.7)
Total: 7 (HIGH: 7, CRITICAL: 0)
...
$ echo "code de sortie : $?"
code de sortie : 1

Code 1 : dans le workflow, l'étape échouerait et la signature n'aurait pas lieu.

Signature et vérification, ici avec une paire de clés et sans journal public (leçon 9), là où la CI utilise la signature sans clé :

$ cosign sign --yes --key cosign.key --signing-config signing-config-local.json --allow-http-registry $IMAGE@sha256:cd7db6c4...
Pushing signature to: 127.0.0.1:5000/lyneko-apps/signalements
$ cosign verify --key cosign.pub --insecure-ignore-tlog=true --allow-http-registry $IMAGE:main-7f907ea
...
  - The signatures were verified against the specified public key

Un second agent, neuf, avec le cache :

$ docker buildx create --name agent2 --driver docker-container --driver-opt network=host --buildkitd-config buildkitd.toml
$ /usr/bin/time -f "agent neuf, tests : %e s" docker buildx build --builder agent2 --progress=quiet \
    --target test --cache-from type=registry,ref=$IMAGE:cache .
agent neuf, tests : 17.52 s
$ /usr/bin/time -f "agent neuf, construction : %e s" docker buildx build --builder agent2 --progress=quiet \
    --platform linux/amd64 -t $IMAGE:$TAG --build-arg SOURCE_DATE_EPOCH \
    --output type=image,push=true,rewrite-timestamp=true --sbom=true --provenance=mode=max \
    --cache-from type=registry,ref=$IMAGE:cache --cache-to type=registry,ref=$IMAGE:cache,mode=max \
    --metadata-file meta2.json .
agent neuf, construction : 12.24 s
$ for m in meta meta2; do
    docker buildx imagetools inspect $IMAGE@$(jq -r '."containerimage.digest"' $m.json) --raw \
      | jq -r '.manifests[] | select(.platform.os=="linux") | .digest'
  done
sha256:567ad4db6824484743298c83a93b5a469094dfc768d41fe1d8097e7f747a5a43
sha256:567ad4db6824484743298c83a93b5a469094dfc768d41fe1d8097e7f747a5a43

Le second agent construit en 12 secondes au lieu de 33 grâce au cache, et, surtout, les deux agents produisent exactement le même manifeste d'image : la construction est reproductible d'un agent à l'autre (leçon 6). Les index diffèrent, puisque la provenance de chaque construction est différente (leçon 8).

Les tests, eux, prennent encore 17,5 secondes sur l'agent neuf : l'étape test n'appartient pas au graphe de l'image finale, son cache n'est donc pas exporté par la seconde construction. Pour l'accélérer aussi, il faudrait exporter le cache de la construction de test (cache-to sur les deux étapes, vers deux étiquettes distinctes).

Sans démon privilégié ?

Le workflow s'appuie sur BuildKit lancé par setup-buildx-action dans un conteneur, sur un agent jetable fourni par GitHub : c'est acceptable, l'agent est détruit après chaque travail. Sur des agents auto-hébergés et partagés (un cluster Kubernetes qui exécute les tâches de CI, par exemple), donner à chaque tâche un BuildKit privilégié ou l'accès au socket Docker revient à lui donner l'hôte (cours précédent, leçon 3). Les solutions sans privilège existent, avec leurs limites.

BuildKit rootless. BuildKit sait tourner sans root, dans un user namespace. Sur la machine de test (Ubuntu 24.04), l'image officielle échoue pourtant au démarrage :

$ docker run -d --name bk moby/buildkit:rootless --oci-worker-no-process-sandbox
$ docker logs bk
[rootlesskit:parent] error: failed to start the child: fork/exec /proc/self/exe: operation not permitted
$ docker run -d --name bk --security-opt seccomp=unconfined --security-opt apparmor=unconfined \
    --security-opt systempaths=unconfined moby/buildkit:rootless --oci-worker-no-process-sandbox
$ docker logs bk
... level=warning msg="[rootlesskit:parent] This error might have happened because /proc/sys/kernel/apparmor_restrict_unprivileged_userns is set to 1" error="fork/exec /proc/self/exe: permission denied"
[rootlesskit:parent] error: failed to start the child: fork/exec /proc/self/exe: permission denied

Même en levant les profils seccomp et AppArmor du conteneur, la restriction AppArmor des user namespaces non privilégiés de l'hôte Ubuntu (cours précédent, leçon 2) l'en empêche, et le message le dit lui-même. Il faudrait installer un profil AppArmor dédié sur l'hôte, ou lever cette restriction, ce qui est une décision d'administration de la plateforme de CI, pas d'un workflow. Les agents GitHub ubuntu-24.04 ont la même restriction.

Kaniko, longtemps la réponse standard pour construire dans Kubernetes sans démon, est archivé depuis juin 2025 :

$ gh api repos/GoogleContainerTools/kaniko -q '{archived, pushed_at}'
{"archived":true,"pushed_at":"2025-06-03T14:36:10Z"}

Un outil de sécurité de la chaîne d'approvisionnement qui ne reçoit plus de correctifs n'a plus sa place dans une chaîne neuve. Buildah (projet Red Hat, version 1.45.1 au 1er octobre 2026) reste une alternative maintenue, rootless et sans démon, qui comprend les Dockerfiles ; il se heurte aux mêmes questions de user namespaces sur l'hôte.

Sous le capot

Ce que fait setup-buildx-action. Elle crée un builder docker-container (comme aux leçons 3 et 6), le démarre, et en fait le builder par défaut des étapes suivantes. Sur un agent hébergé par GitHub, ce builder est neuf à chaque exécution : sans export de cache, chaque construction part de zéro.

Comment GitHub prouve l'identité du workflow. Avec id-token: write, l'étape peut demander à GitHub un jeton OIDC signé, qui contient notamment le dépôt, le chemin du fichier de workflow, la référence Git (refs/heads/main), le commit et le type d'événement. Cosign l'échange auprès de Fulcio contre un certificat de 10 minutes dont le sujet est l'URL du workflow à cette référence (par exemple https://github.com/lyneko-team/signalements/.github/workflows/image.yml@refs/heads/main), et dont l'émetteur est https://token.actions.githubusercontent.com. C'est cette paire identité et émetteur que la politique de vérification exige au déploiement.

Ce que publie metadata-action. Outre les étiquettes, elle produit des étiquettes et annotations OCI (org.opencontainers.image.source, revision, created, version, title...) que build-push-action applique à l'image et à son index. Ce sont elles qui permettent, d'une image dans un cluster, de remonter au commit, même sans lire la provenance.

Pièges courants

Analyser l'image par son étiquette. Entre la publication et l'analyse, l'étiquette peut avoir été déplacée par une autre exécution (deux commits rapprochés). Analysez et signez toujours IMAGE@empreinte, avec l'empreinte renvoyée par la construction.

Signer ce qui n'a pas été analysé. Si l'étape d'analyse a continue-on-error: true, ou si l'analyse est faite après la signature, la signature ne garantit plus rien. L'ordre du workflow est une décision de sécurité.

Un cache empoisonné par une demande de fusion. Si les demandes de fusion peuvent écrire dans le cache de la branche principale, un contributeur malveillant peut y placer une couche qui sera réutilisée par la prochaine construction de main. Le workflow n'exporte le cache que sur push vers main.

pull_request_target. Ce déclencheur exécute le workflow avec les secrets du dépôt, en réponse à une demande de fusion venant d'un fork. Combiné à un checkout du code de la demande de fusion, il donne au contributeur l'accès aux secrets. N'utilisez pas pull_request_target pour construire du code proposé.

Une étiquette d'action déplacée. uses: docker/build-push-action@v7 suit l'étiquette ; si elle est réécrite, la prochaine exécution utilise un autre code. L'épinglage par empreinte de commit l'empêche, et Dependabot (écosystème github-actions) propose les mises à jour.

Le cache de l'étape de test. Mesuré ci-dessus : sur un agent neuf, l'étape test ne profite pas du cache exporté par la construction finale. Exportez-le séparément si la durée des tests compte.

Sécurité

  • Moindre privilège partout. Jeton en lecture seule par défaut ; id-token: write seulement dans le travail qui signe ; clé du registre limitée à l'écriture dans un espace de noms ; aucun secret pour les demandes de fusion.
  • Durcissez les agents. Agents jetables de préférence ; si les agents sont auto-hébergés, une tâche par machine éphémère, jamais d'accès au socket Docker de l'hôte, et des réseaux de sortie restreints (une étape de construction compromise ne doit pas pouvoir joindre n'importe quel serveur).
  • Protégez la branche qui publie. La signature dit « construit par ce workflow, sur refs/heads/main » : elle ne vaut que si main est protégée (revue obligatoire, pas de poussée directe, vérifications requises).
  • Ce que vise SLSA niveau 3. Plateforme hébergée, provenance produite et signée par la plateforme elle-même (et non par une étape que le projet pourrait modifier), isolation entre constructions. Le workflow de cette leçon atteint le niveau 1 (provenance jointe), et sa signature sans clé par l'identité du workflow apporte une partie des garanties du niveau 2 ; le générateur de provenance officiel du projet SLSA pour GitHub Actions (slsa-github-generator) produit une provenance qui l'atteint.

En production

  • Déployer, c'est un autre dépôt. La chaîne s'arrête à la publication d'une image signée. Le déploiement (écrire la nouvelle empreinte dans les manifestes, et laisser Argo CD les appliquer) relève de l'approche GitOps, traitée dans le chapitre Livrer ; c'est ainsi qu'est déployé ce site, dont la CI écrit l'étiquette main-<commit> dans deploy/chart/values.yaml.
  • Reconstruisez régulièrement, même sans commit : une exécution programmée (schedule:) chaque semaine, avec --pull, récupère les correctifs des images de base et refait l'analyse (leçon 1).
  • Analysez aussi ce qui tourne déjà. L'analyse dans la CI ne voit que l'image au moment de sa construction ; une vulnérabilité publiée le lendemain concerne une image déjà déployée. Une analyse périodique des images en production, à partir des SBOM ou des images elles-mêmes (leçon 8), complète la chaîne.
  • Mesurez la chaîne. Durée de bout en bout, taux de réussite du cache, nombre d'échecs de l'analyse : une chaîne lente ou souvent rouge finit contournée. Les indicateurs DORA (cours Mesurer : DORA et SPACE) donnent le cadre.

Exercices

1. Lire le workflow. Pour une demande de fusion ouverte depuis un fork, listez les étapes exécutées et celles qui sont sautées. Le contributeur peut-il faire publier une image ? Faire signer une image ? Lire SCW_SECRET_KEY ?

Solution

Exécutées : récupération des sources, date de référence, préparation de Buildx, étiquettes, tests, construction (sans publication, puisque push=false). Sautées : connexion au registre, analyse, installation de Cosign, signature. Le contributeur ne peut ni publier, ni signer, ni lire le secret : GitHub ne fournit pas les secrets aux workflows pull_request déclenchés depuis un fork, et les étapes sensibles sont conditionnées à push. Il peut en revanche faire exécuter son code dans l'étape de test, sur un agent jetable sans secrets : c'est le risque accepté de toute CI ouverte aux contributions.

2. Ajouter arm64 (niveau 300). Signalements ne se compile pas en croisé. Proposez la structure d'un workflow qui publie une image linux/amd64 et linux/arm64 sous une seule étiquette, avec des agents natifs.

Solution

Une matrice de deux travaux de construction, runs-on: ubuntu-24.04 et runs-on: ubuntu-24.04-arm, chacun construisant sa plateforme et la publiant par empreinte (sans étiquette, ou avec une étiquette technique), en exportant l'empreinte comme sortie du travail. Puis un travail de fusion, qui dépend des deux (needs:), crée l'index avec docker buildx imagetools create -t $IMAGE:main-<commit> $IMAGE@<empreinte-amd64> $IMAGE@<empreinte-arm64>, puis analyse et signe l'index. La documentation de Docker décrit ce motif sous le nom de distribute build across multiple runners.

3. Faire échouer la chaîne (niveau 200). Localement, rejouez la chaîne avec un Dockerfile dont l'étape d'exécution n'a plus apt-get upgrade (leçon 2). À quelle étape la chaîne s'arrête-t-elle, et avec quel code ? Que se passe-t-il pour la signature ?

Solution

La construction et la publication réussissent ; l'analyse Trivy trouve les vulnérabilités hautes corrigeables d'OpenSSL que la mise à jour corrigeait et sort avec le code 1 (comme sur python:3.14-slim dans la leçon). Dans le workflow, l'étape échoue et les suivantes (Cosign) ne s'exécutent pas : l'image est publiée mais non signée, donc refusée au déploiement. On corrige en rétablissant la mise à jour, ou en attendant une image de base reconstruite.

4. Auditer une chaîne existante (niveau 400). Le workflow d'un projet hérité contient : on: pull_request_target, uses: actions/checkout@v4 avec ref: ${{ github.event.pull_request.head.sha }}, un docker build suivi d'un docker push avec les identifiants du registre, et permissions: write-all. Listez les risques par gravité et proposez une version corrigée.

Solution

Le plus grave : pull_request_target avec récupération du code de la demande de fusion exécute du code non relu avec les secrets et le jeton du dépôt ; un fork peut exfiltrer les identifiants du registre et publier n'importe quoi. Ensuite : permissions: write-all donne au jeton le droit de modifier le dépôt (code, étiquettes, publications). Puis : publication sans tests, sans analyse ni signature ; action épinglée par étiquette. Version corrigée : la structure de la leçon (pull_request pour construire et tester sans secrets, push sur main pour publier), permissions: contents: read par défaut, actions épinglées par empreinte, analyse bloquante et signature, cache exporté seulement depuis main.

Récapitulatif

  • La CI est le seul système qui publie : elle teste, construit de façon reproductible avec cache, atteste (SBOM, provenance), analyse de façon bloquante et signe, à chaque commit.
  • Droits minimaux : jeton en lecture, id-token: write pour la signature sans clé, clé de registre limitée ; aucun secret ni publication pour les demandes de fusion ; actions épinglées par empreinte.
  • On publie, analyse et signe par empreinte ; une image vulnérable reste non signée, donc non déployable.
  • actionlint valide le workflow avant de le pousser ; la chaîne se rejoue localement étape par étape.
  • Sur des agents neufs, le cache exporté ramène la construction de 33 à 12 secondes, et deux agents produisent le même manifeste.
  • Construire sans démon privilégié reste difficile sur Ubuntu 24.04 (restriction des user namespaces), et Kaniko est archivé : préférez des agents jetables.

Pour aller plus loin

  • La section GitHub Actions de la documentation Docker, en particulier les pages sur le cache, les constructions multi-plateformes réparties et les attestations.
  • Le guide Security hardening for GitHub Actions de GitHub, à relire avant d'ouvrir une CI aux contributions externes.
  • Le chapitre Livrer de ce site, qui prend la suite : stratégies de déploiement et GitOps avec Argo CD.
Voir ma constellation →

Sources