Environnements, secrets et déploiement
Pourquoi
Jusqu'ici, le pipeline de Signalements vérifie, construit et publie. Il ne déploie pas : l'image attend dans le registre. Déployer, c'est la première action du pipeline qui touche un système que des utilisateurs emploient, et elle pose trois questions que les leçons précédentes ont pu esquiver.
Avec quels identifiants ? Pour agir sur un registre, un cluster ou un fournisseur de nuage, le pipeline a besoin d'un secret. Où le ranger, qui peut le lire, et que se passe-t-il s'il fuit ?
Qui décide ? Une fusion sur main peut partir en recette sans intervention. Une mise en production, non : quelqu'un doit dire oui, et ce ne doit pas être la personne qui a écrit le changement.
Que se passe-t-il quand deux déploiements se croisent ? Deux fusions rapprochées, une relance, une version publiée pendant un déploiement de recette : le pipeline doit sérialiser, sans perdre ni interrompre de déploiement.
GitHub Actions répond avec deux mécanismes, les secrets et les environnements. Cette leçon les met en œuvre selon le modèle de déploiement de Lyneko, le GitOps, puis analyse sans complaisance le workflow réel de ce site, qui montre ce que l'on peut protéger, et ce que l'on ne peut pas, avec le plan gratuit de GitHub.
Les concepts
Pousser ou tirer
Il y a deux façons de déployer depuis un pipeline :
flowchart LR
subgraph Pousser
CI1["Pipeline"] -->|"kubectl / helm<br/>avec les identifiants du cluster"| K1["Cluster"]
end
subgraph Tirer["Tirer (GitOps)"]
CI2["Pipeline"] -->|"commit : étiquette"| G["Dépôt Git"]
A["Argo CD<br/>(dans le cluster)"] -->|"lit"| G
A -->|"applique"| K2["Cluster"]
end
Dans le modèle poussé, le pipeline détient les identifiants du cluster et y applique les changements. Dans le modèle tiré, le GitOps, le pipeline se contente d'écrire dans un dépôt Git ce qui doit être déployé ; un agent qui vit dans le cluster (Argo CD chez Lyneko) lit ce dépôt et aligne le cluster dessus. Le second a une propriété décisive pour la sécurité : le pipeline n'a aucun accès au cluster. Un pipeline compromis peut écrire une mauvaise étiquette dans Git, ce qui se voit dans l'historique et s'annule par un revert, mais il ne peut pas exécuter de commande dans le cluster. C'est le modèle de ce site et des applications de Lyneko ; le cours GitOps avec Argo CD le détaille côté cluster.
Les secrets
Un secret est une valeur chiffrée, rangée à l'un de trois niveaux :
| Niveau | Visible par | Limite |
|---|---|---|
| Organisation | les dépôts choisis (tous, privés seulement, ou une liste) | 1 000 secrets |
| Dépôt | tous les workflows du dépôt | 100 secrets |
| Environnement | seulement les jobs qui déclarent cet environnement, après ses règles de protection | 100 secrets |
Un secret fait au plus 48 Ko. Quand le même nom existe à plusieurs niveaux, le plus précis l'emporte. Un workflow ne lit un secret que s'il le nomme explicitement (${{ secrets.NOM }}), à l'exception du GITHUB_TOKEN, toujours présent. Les runs déclenchés par une demande de fusion venue d'une bifurcation ne reçoivent aucun secret ; ceux de Dependabot ne reçoivent aucun secret d'Actions, seulement les secrets propres à Dependabot.
Le chiffrement commence sur le poste ou dans l'outil qui crée le secret. GitHub publie pour chaque dépôt une clé publique ; le client chiffre la valeur avec elle dans une boîte scellée libsodium, et seul GitHub, qui détient la clé privée, peut l'ouvrir pour l'injecter dans un job. La démonstration suivante reproduit ce mécanisme avec une paire de clés de test :
"""Le chiffrement des secrets de GitHub Actions : une « boîte scellée » libsodium.
GitHub publie une clé publique par dépôt ; le client chiffre le secret avec elle,
et seule la clé privée, que GitHub garde, peut le déchiffrer.
"""
import base64
from nacl.public import PrivateKey, SealedBox
cle_privee = PrivateKey.generate() # côté GitHub, jamais publiée
cle_publique = cle_privee.public_key # publiée par l'API du dépôt
print("clé publique :", base64.b64encode(bytes(cle_publique)).decode())
secret = b"scw-cle-d-api-de-test"
for essai in (1, 2):
chiffre = SealedBox(cle_publique).encrypt(secret)
print(f"chiffrement {essai} :", base64.b64encode(chiffre).decode())
print("déchiffré :", SealedBox(cle_privee).decrypt(chiffre).decode())
print("taille :", len(secret), "octets en clair,", len(chiffre), "chiffrés")$ uv run --with pynacl python boite_scellee.py
clé publique : j5AfAcBbCmargGq2ZlZDNc4aa1TGoAoy+epM2CFlwmQ=
chiffrement 1 : 4qOeYRWCbSqP9YHOQglvYFLIaHuxjctN3FrItRAikBnSyBdt718yk3kYnWHm9k3/53B7sD6STOGKiSrbDEpWjBWBineM
chiffrement 2 : BrIsUy7ZHDDHASrSBpPEg/QEb8XAOIdgjA1h9VPkKGBRhIPx65rZiAvWujwv0YMRBgWs5O/SQ8C4ZOKjSe/lwL+VPRwM
déchiffré : scw-cle-d-api-de-test
taille : 21 octets en clair, 69 chiffrés
Le même secret donne deux chiffrés différents : chaque boîte embarque une clé éphémère de 32 octets, d'où les 48 octets de plus (32 de clé, 16 de code d'authentification). Personne ne peut relire la valeur d'un secret depuis l'interface ou l'API de GitHub, pas même un administrateur : on peut seulement la remplacer.
Pendant le job, le runner masque dans les journaux toute occurrence de la valeur d'un secret, remplacée par ***. Le masquage compare des chaînes : une valeur transformée (encodée en base64, découpée, inversée) n'est plus reconnue. C'est un filet, pas une protection.
Les environnements
Un environnement est une cible de déploiement déclarée dans les réglages du dépôt (recette, production). Un job s'y rattache par la clé environment:. L'environnement apporte trois choses :
- Ses propres secrets et variables, que seuls les jobs rattachés reçoivent.
- Des règles de protection, évaluées avant que le job ne reçoive un runner et ses secrets :
- relecteurs requis : jusqu'à six personnes ou équipes, dont une seule doit approuver ; une option empêche la personne qui a déclenché le déploiement de l'approuver elle-même ;
- minuterie d'attente : de 1 minute à 30 jours, non facturée ;
- branches et étiquettes autorisées : seules les références qui correspondent aux motifs peuvent déployer ;
- contournement par les administrateurs, autorisé par défaut, que l'on peut interdire ;
- règles personnalisées, déléguées à une application (un outil de gestion des changements, un contrôle de supervision).
- Un historique : chaque job rattaché crée un déploiement (au sens de l'API de GitHub), avec son statut et son adresse, visible sur la page du dépôt.
La règle de branches est évaluée contre la référence du run (GITHUB_REF). Depuis décembre 2025, pour les événements de demande de fusion, c'est la référence de fusion refs/pull/<n>/merge qui est comparée : une règle main n'autorise donc plus un workflow de demande de fusion à déployer, même si la demande vise main.
Ce que permet votre plan
C'est le point que la documentation de GitHub répète en petites notes, et qu'il faut regarder en face avant de concevoir un déploiement :
| Fonction | Dépôt public | Dépôt privé, plan gratuit | Dépôt privé, Pro ou Team | Dépôt privé, Enterprise |
|---|---|---|---|---|
| Secrets et variables d'organisation | oui | non | oui | oui |
| Environnements, leurs secrets et variables | oui | non | oui | oui |
| Branches et étiquettes autorisées | oui | non | oui | oui |
| Relecteurs requis, minuterie | oui | non | non | oui |
| Interdire le contournement par les administrateurs | oui | non | non | oui |
Autrement dit, sur un dépôt privé, une organisation au plan gratuit n'a que des secrets et variables de dépôt : ni secrets d'organisation, ni environnements, ni secrets d'environnement, ni approbations. C'est la situation des dépôts de Lyneko, dont celui de ce site, et la dernière section de En pratique en tire les conséquences.
En pratique
Le déploiement GitOps de Signalements
Le dépôt de Signalements reçoit deux fichiers de valeurs pour son chart Helm, un par environnement :
# deploy/chart/values-recette.yaml
# Signalements, environnement de recette
image:
repository: ghcr.io/lyneko-formation/signalements
tag: main-6ef8842 # écrit par la CI à chaque fusion sur main
replicas: 1# deploy/chart/values-production.yaml
# Signalements, environnement de production
image:
repository: ghcr.io/lyneko-formation/signalements
tag: 1.1.0 # écrit par la CI à chaque version publiée, après approbation
replicas: 3Deux applications Argo CD (hors de ce cours) suivent ces deux fichiers sur main. Le pipeline n'a qu'à y écrire la bonne étiquette, avec l'action ecrire-etiquette de la leçon 8, puis à pousser le commit.
Pousser sans perdre la course
Entre le moment où le job récupère le dépôt et celui où il pousse, quelqu'un peut avoir fusionné autre chose sur main. La poussée est alors refusée. Le script ci/pousser.sh valide les fichiers donnés, pousse, et en cas de refus se recale sur le dépôt distant avant de réessayer :
#!/usr/bin/env bash
# Valide les fichiers donnés et pousse sur la branche courante, en se recalant
# sur le dépôt distant si quelqu'un a poussé entre-temps.
# Usage : ci/pousser.sh "<message>" <fichier>...
set -euo pipefail
message=$1; shift
branche=$(git rev-parse --abbrev-ref HEAD)
git add -- "$@"
if git diff --cached --quiet; then
echo "rien à valider"
exit 0
fi
git commit --quiet -m "$message"
for tentative in 1 2 3; do
if git push --quiet origin "HEAD:$branche"; then
echo "poussé : $(git log -1 --format='%h %s')"
exit 0
fi
echo "poussée refusée (tentative $tentative) : recalage sur origin/$branche"
git pull --quiet --rebase origin "$branche"
done
echo "échec après 3 tentatives" >&2
exit 1Le cas de la course se reproduit localement avec un dépôt nu et deux clones : le clone ci modifie l'étiquette pendant que le clone collegue pousse un autre commit.
$ cd collegue && echo "# Signalements" > README.md && git add README.md && git commit -qm "README" && git push -q origin main
$ cd ../ci && sed -i 's/tag: main-6ef8842/tag: main-a1b2c3d/' deploy/chart/values-recette.yaml
$ ci/pousser.sh "Deploy recette a1b2c3d" deploy/chart/values-recette.yaml
To .../distant.git
! [rejected] HEAD -> main (fetch first)
error: failed to push some refs to '.../distant.git'
hint: Updates were rejected because the remote contains work that you do not
hint: have locally. This is usually caused by another repository pushing to
hint: the same ref. If you want to integrate the remote changes, use
hint: 'git pull' before pushing again.
hint: See the 'Note about fast-forwards' in 'git push --help' for details.
poussée refusée (tentative 1) : recalage sur origin/main
poussé : 610b9fb Deploy recette a1b2c3d
$ git log --oneline origin/main
610b9fb Deploy recette a1b2c3d
ee0334c README
ff13713 Chart et scripts
8a7ff70 Depart
$ ci/pousser.sh "Deploy recette a1b2c3d" deploy/chart/values-recette.yaml
rien à valider
Le commit de déploiement s'est replacé au-dessus de celui du collègue ; relancé, le script ne crée pas de commit vide. Le recalage par rebase est sans risque ici : le commit ne touche qu'un fichier que seul le pipeline modifie, il ne peut pas entrer en conflit avec le travail des développeurs.
Le workflow de livraison
Le workflow image.yml de la leçon 7 devient livraison.yml : il appelle toujours le workflow réutilisable d'image, puis ajoute deux jobs de déploiement.
name: Livraison
on:
push:
branches: [main]
tags: ["v*"]
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: read
defaults:
run:
shell: bash
jobs:
image:
uses: ./.github/workflows/image-multiarch.yml
permissions:
contents: read
packages: write
id-token: write
attestations: write
artifact-metadata: write
with:
publier: ${{ github.event_name != 'pull_request' }}
recette:
name: Déployer en recette
needs: image
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-24.04
timeout-minutes: 5
environment:
name: recette
url: https://signalements-recette.apps.lyneko.com
concurrency:
group: deploiement-recette
cancel-in-progress: false
permissions:
contents: write
steps:
- uses: actions/checkout@v7
# Même règle que metadata-action (type=sha,prefix=main-) : 7 caractères.
- name: Calculer l'étiquette
id: version
run: echo "etiquette=main-${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
- name: Écrire l'étiquette
id: etiquette
uses: lyneko-team/ecrire-etiquette@c865d9964a40297c03b391d1a9ff0f3b2499c735 # v1.0.0
with:
fichier: deploy/chart/values-recette.yaml
valeur: ${{ steps.version.outputs.etiquette }}
- name: Valider et pousser
if: steps.etiquette.outputs.modifie == 'true'
env:
ETIQUETTE: ${{ steps.version.outputs.etiquette }}
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
ci/pousser.sh "Deploy recette $ETIQUETTE" deploy/chart/values-recette.yaml
production:
name: Déployer en production
needs: image
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-24.04
timeout-minutes: 5
environment:
name: production
url: https://signalements.apps.lyneko.com
concurrency:
group: deploiement-production
cancel-in-progress: false
permissions:
contents: write
steps:
- uses: actions/checkout@v7
with:
ref: main
# Même règle que metadata-action (type=semver,pattern={{version}}) : sans le « v ».
- name: Calculer l'étiquette
id: version
run: echo "etiquette=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
- name: Écrire l'étiquette
id: etiquette
uses: lyneko-team/ecrire-etiquette@c865d9964a40297c03b391d1a9ff0f3b2499c735 # v1.0.0
with:
fichier: deploy/chart/values-production.yaml
valeur: ${{ steps.version.outputs.etiquette }}
- name: Valider et pousser
if: steps.etiquette.outputs.modifie == 'true'
env:
ETIQUETTE: ${{ steps.version.outputs.etiquette }}
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
ci/pousser.sh "Deploy production $ETIQUETTE" deploy/chart/values-production.yamlLes points essentiels :
environment:rattache chaque job à son environnement, avec une adresse affichée dans l'historique des déploiements et sur la page du run.Le calcul de l'étiquette reproduit exactement la règle de
metadata-actiondans le workflow d'image. La première version de ce workflow écrivaitmain-${{ github.sha }}(40 caractères) et${{ github.ref_name }}(v1.2.0, avec le « v ») : Argo CD aurait cherché des étiquettes qui n'existent pas. L'erreur a été trouvée à la relecture, en comparant avec les étiquettes réellement produites ; le commentaire au-dessus de chaque calcul rappelle le lien. Vérifié localement :$ GITHUB_SHA=6ef8842a1b2c3d4e5f60718293a4b5c6d7e8f901 bash -c 'echo "etiquette=main-${GITHUB_SHA::7}"' etiquette=main-6ef8842 $ GITHUB_REF_NAME=v1.2.0 bash -c 'echo "etiquette=${GITHUB_REF_NAME#v}"' etiquette=1.2.0Les références des actions :
checkoutest encore désigné par son étiquette (@v7), comme dans les leçons précédentes, tandis que l'actionecrire-etiquettel'est déjà par empreinte ; la leçon 11 épingle tout. L'actionlyneko-team/ecrire-etiquetteest celle de la leçon 8 : elle n'est pas publiée dans le cadre de ce cours, publiez-la dans votre compte pour suivre.La production récupère
main(ref: main) et non l'étiquette : le fichier de valeurs vit surmain, et c'est là que le commit de déploiement doit aller.contents: writeseulement sur les deux jobs qui poussent.checkoutgarde ici ses identifiants (pas depersist-credentials: false) : c'est ce qui permet la poussée. zizmor le signale (warning[artipacked], gravité moyenne), et c'est un choix assumé : le job ne téléverse aucun artefact.Un groupe de concurrence par environnement, sans annulation : deux fusions rapprochées produisent deux déploiements successifs, jamais simultanés, jamais interrompus. Avec la file par défaut, un déploiement en attente est remplacé par le suivant, ce qui convient (seule la dernière étiquette compte) ;
queue: max(leçon 2) garderait toute la file.Aucune boucle : le commit
Deploy ...est poussé avec leGITHUB_TOKEN, qui ne déclenche pas de nouveau run (leçon 2).
Configurer les environnements
Sur un dépôt public (ou privé avec un plan qui le permet), dans Settings > Environments :
recette | production | |
|---|---|---|
| Branches et étiquettes autorisées | main | étiquettes v* |
| Relecteurs requis | aucun | l'équipe d'exploitation, sans auto-approbation |
| Contournement par les administrateurs | autorisé | interdit |
| Secrets | ceux de la recette | ceux de la production |
Quand une étiquette v1.2.0 est poussée, le run construit l'image, puis le job production s'arrête en Waiting : les relecteurs reçoivent une notification, voient les changements, et approuvent ou rejettent. Le job ne reçoit un runner et les secrets de production qu'après l'approbation. Une approbation attend au plus 30 jours ; au-delà, le run échoue.
Les secrets se déposent depuis le terminal, par niveau, avec gh (qui chiffre la valeur localement avec la clé publique du dépôt avant de l'envoyer) :
$ gh secret set SCW_SECRET_KEY --env production # lit la valeur sur l'entrée standard
$ gh secret set SCW_SECRET_KEY --org lyneko-formation --visibility selected --repos signalements
La seconde commande dépose un secret d'organisation visible du seul dépôt signalements ; rappelez-vous qu'au plan gratuit, un dépôt privé ne le verrait pas.
Ne passez pas la valeur avec --body dans une commande tapée : elle resterait dans l'historique du shell.
Quand main est protégée
Sur un dépôt dont la branche main exige des demandes de fusion relues, le GITHUB_TOKEN ne peut pas y pousser directement, et le commit de déploiement est refusé. Deux solutions propres : écrire les fichiers de valeurs dans un dépôt de déploiement séparé (le dépôt GitOps), ou pousser avec le jeton d'une GitHub App dédiée, à qui la règle de protection accorde une exception. Le jeton d'application se génère dans le job, valable une heure, avec actions/create-github-app-token :
- name: Jeton de l'application de déploiement
id: jeton
uses: actions/create-github-app-token@v3
with:
client-id: ${{ vars.DEPLOIEMENT_CLIENT_ID }}
private-key: ${{ secrets.DEPLOIEMENT_CLE_PRIVEE }}
permission-contents: write
- uses: actions/checkout@v7
with:
token: ${{ steps.jeton.outputs.token }}Petite incohérence d'outillage, une de plus : la version 3 de l'action remplace l'entrée app-id par client-id (l'ancienne est marquée dépréciée dans son action.yml), mais la description de l'action embarquée dans actionlint 1.7.12 est antérieure et réclame encore app-id :
$ actionlint .github/workflows/app.yml
.github/workflows/app.yml:12:15: missing input "app-id" which is required by action "actions/create-github-app-token@v3". all required inputs are "app-id", "private-key" [action]
.github/workflows/app.yml:14:11: input "client-id" is not defined in action "actions/create-github-app-token@v3". ...
C'est l'action qui fait foi : sa propre définition à la version utilisée.
Le cas réel : le déploiement de ce site
Le workflow qui publie apprendre.lyneko.com (cité dès la leçon 1) suit le même modèle GitOps : vérification, image publiée sur le registre Scaleway, écriture de l'étiquette dans deploy/chart/values.yaml, commit Deploy <sha>, et Argo CD déploie. Ce qu'il fait bien :
- le modèle tiré : aucun identifiant du cluster Kapsule dans GitHub ;
permissions: contents: readpar défaut, etcontents: writesur le seul job qui pousse ;- la concurrence du job de déploiement, sans annulation ;
- le retour arrière par un simple revert de la ligne
tag:, tracé dans Git.
Ce qu'il ne peut pas faire : le dépôt est privé et l'organisation est au plan gratuit. Il n'y a donc pas d'environnement, et la clé du registre Scaleway est un secret de dépôt. Conséquence concrète : tout workflow de ce dépôt, sur n'importe quelle branche, peut lire cette clé. Une personne qui a le droit de pousser une branche peut y modifier un workflow pour qu'il se déclenche sur sa poussée et utilise la clé. Avec un environnement limité à main, ce workflow de branche n'obtiendrait pas le secret ; sans environnement, rien ne l'en empêche.
La compensation repose sur l'autre côté, le fournisseur, et sur trois mesures prises chez Scaleway :
- la clé appartient à une application IAM dédiée au pipeline de ce site, pas à une personne ;
- la politique de cette application ne donne accès qu'au registre de conteneurs (l'ensemble de permissions
ContainerRegistryFullAccess, parmi les deux qui concernent le registre) : une clé volée permet de publier, d'écraser ou de supprimer des images du registre, mais pas de toucher au cluster, au stockage ou à la facturation ; - la clé expire : elle a été créée pour un an, et devra être renouvelée avant son échéance (la commande
scw iam api-key listaffiche la date d'expiration de chaque clé).
Et une mesure organisationnelle : seules des personnes de confiance ont le droit de pousser sur ces dépôts. C'est un arbitrage explicite (le coût du plan Team contre un risque borné par la portée de la clé), qu'il vaut mieux écrire que découvrir. La leçon suivante montre comment, chez les fournisseurs qui le permettent, on se passe complètement de ce type de clé.
Sous le capot
Quand un job déclare environment:, GitHub crée un objet deployment dans l'API du dépôt, puis évalue les règles de protection avant d'attribuer le job à un runner. Tant qu'une règle n'est pas satisfaite (relecteur, minuterie, règle personnalisée), le job reste en attente sans consommer de minutes. Une fois les règles satisfaites, le message de job envoyé au runner contient les secrets de l'environnement, et seulement à ce moment-là. C'est ce qui fait de l'environnement une frontière de sécurité réelle : un job non approuvé ne voit jamais les secrets, même brièvement.
La règle de branches est vérifiée à la création du job, contre GITHUB_REF. Elle protège contre le scénario décrit plus haut : une branche qui modifie le workflow pour déclarer environment: production obtient un job qui échoue immédiatement, faute d'être une référence autorisée, avant toute exécution.
Le masquage est fait par le runner, dans le flux des journaux : il connaît les valeurs des secrets du job (et celles déclarées par ::add-mask::) et remplace chaque occurrence par *** avant l'envoi à GitHub. Pour une valeur sur plusieurs lignes (une clé privée PEM), le runner masque la valeur entière et chacune de ses lignes séparément (le code de Worker.cs découpe la valeur sur les retours à la ligne) ; une ligne très courte ou très commune peut provoquer des masquages inattendus ailleurs dans le journal.
Les historiques de déploiement de l'API sont une source de données directe pour les indicateurs DORA du cours précédent : fréquence des déploiements par environnement, délai entre commit et déploiement, taux d'échec.
Pièges courants
Un secret d'environnement est vide. Le job ne déclare pas environment:, ou déclare un autre environnement. Les secrets d'environnement ne sont pas visibles des autres jobs.
Le job de production échoue immédiatement : la référence n'est pas autorisée. L'environnement n'accepte que v* et le run vient d'une branche, ou d'une étiquette au mauvais format. Pour une demande de fusion, rappelez-vous que c'est refs/pull/<n>/merge qui est comparé.
Le commit de déploiement est refusé : protected branch hook declined. main est protégée et le GITHUB_TOKEN n'a pas d'exception. Dépôt GitOps séparé ou jeton d'application.
Deux déploiements s'entrelacent. Pas de groupe de concurrence, ou un groupe avec cancel-in-progress: true qui interrompt un déploiement en cours. Un groupe par environnement, sans annulation.
Le déploiement écrit une étiquette qui n'existe pas. Le calcul de l'étiquette dans le job de déploiement ne suit pas la règle du workflow d'image. Gardez les deux règles côte à côte, commentées, ou faites remonter l'étiquette exacte en sortie du workflow d'image.
Un secret apparaît en clair dans un journal. Il a été transformé avant d'être affiché (base64, JSON échappé), ou c'est une valeur obtenue en cours de job sans ::add-mask::. Considérez-le comme compromis : révoquez-le chez le fournisseur, puis remplacez-le.
Les environnements n'apparaissent pas dans les réglages. Dépôt privé au plan gratuit : la fonction n'existe pas.
Sécurité
La frontière, c'est l'environnement, pas le secret. Un secret de dépôt est lisible par tout workflow de toute branche ; un secret d'environnement protégé par une règle de branches ne l'est que par les jobs autorisés, après approbation éventuelle. Rangez les secrets de production dans un environnement dès que votre plan le permet.
Le principe de séparation. L'option qui interdit l'auto-approbation réalise le principe des quatre yeux : la personne qui déclenche une mise en production ne peut pas la valider seule. Associée à l'interdiction du contournement par les administrateurs, elle rend l'approbation réellement obligatoire.
La portée chez le fournisseur. Un secret de pipeline doit être une identité technique dédiée, aux droits minimaux, avec une date d'expiration, comme la clé Scaleway de ce site. Si elle fuit, le dommage est borné et la fenêtre limitée. Une clé personnelle d'administrateur dans un secret de dépôt est l'erreur la plus coûteuse de toutes.
Le modèle GitOps réduit la surface. Le pipeline n'a pas d'identifiants du cluster. Ce qu'il peut faire de pire, écrire une étiquette, est visible, relu (si main est protégée) et réversible.
Ne jamais afficher un secret pour « vérifier ». Même masqué, un echo $SECRET | base64 le révèle. Pour vérifier qu'un secret est présent, vérifiez qu'il n'est pas vide ([ -n "$SECRET" ]) ou utilisez-le réellement.
En production
Une recette au plus près de la production. Le même workflow, la même image, la même mécanique de déploiement pour les deux environnements : seuls le fichier de valeurs, les secrets et les règles changent. C'est le principe de promotion d'un artefact unique du cours précédent.
Rotation des secrets. Notez la date d'expiration de chaque clé de pipeline dans un endroit que quelqu'un consulte (ticket planifié, calendrier d'équipe), et renouvelez avant l'échéance. Une clé expirée arrête les déploiements à un moment que l'on n'a pas choisi.
Mesurer. Les déploiements créés par les environnements fournissent les données DORA sans outillage supplémentaire. Pour un dépôt privé au plan gratuit, sans environnements, les commits Deploy ... sur main jouent ce rôle : git log --grep '^Deploy ' donne la liste des déploiements et leurs dates.
Passer au plan Team ? Pour une organisation qui déploie en production depuis des dépôts privés, les environnements avec règles de branches (disponibles en Team) ferment la faille principale décrite plus haut. Les relecteurs requis sur dépôt privé, eux, demandent Enterprise. L'arbitrage se fait sur une liste de risques écrite, pas sur une impression.
Exercices
1. Pour chacun de ces secrets, choisissez le niveau (organisation, dépôt, environnement) et justifiez : (a) le jeton d'un service d'analyse de code utilisé par tous les dépôts ; (b) la clé du registre Scaleway de recette de Signalements ; (c) la clé de production de Signalements.
Solution
(a) Organisation, avec une visibilité limitée aux dépôts qui l'utilisent : un seul endroit à renouveler. Sauf sur un dépôt privé au plan gratuit, qui ne voit pas les secrets d'organisation : il faut alors un secret par dépôt. (b) Environnement recette si le plan le permet (seuls les jobs de déploiement en recette la reçoivent), sinon dépôt. (c) Environnement production, restreint aux étiquettes v*, avec relecteurs requis : c'est le cas qui justifie à lui seul les environnements.
2. Un développeur pousse une branche essai qui modifie livraison.yml : il ajoute push: branches: [essai] et, dans le job production, une étape run: echo "$SCW_SECRET_KEY" | base64. Que se passe-t-il (a) si la clé est un secret de l'environnement production restreint aux étiquettes v* ; (b) si c'est un secret de dépôt ?
Solution
(a) Le job déclare environment: production ; la référence refs/heads/essai ne correspond pas à v*, le job échoue avant d'obtenir un runner et les secrets. La tentative est visible dans l'historique. (b) Le job s'exécute sur la poussée de la branche, reçoit le secret de dépôt, et l'affiche encodé en base64 : le masquage ne reconnaît pas la valeur transformée. La clé est compromise. D'où l'importance de la portée chez le fournisseur et de l'expiration quand les environnements ne sont pas disponibles.
3. Supposons que livraison.yml n'ait aucun bloc concurrency, ni au niveau du workflow ni au niveau du job. Deux fusions arrivent sur main à dix secondes d'intervalle. Décrivez ce qui peut se passer, pourquoi le script ci/pousser.sh ne suffit pas à tout régler, et ce qu'apporte chacun des deux groupes de concurrence du workflow.
Solution
Deux runs tournent en parallèle. Les deux jobs recette écrivent chacun leur étiquette ; le second à pousser est refusé, se recale, et pousse : le résultat final dépend de l'ordre d'arrivée des poussées, pas de l'ordre des fusions. Si le run de la première fusion pousse en dernier (son image a mis plus longtemps à se construire), la recette revient à l'ancienne version. Le script règle la course technique (la poussée refusée), pas la course logique (l'ordre). Le groupe de concurrence du workflow, par référence et sans annulation sur main, sérialise les runs entiers dans l'ordre de leur arrivée : le second run attend que le premier, déploiement compris, soit terminé. Le groupe du job deploiement-recette, à lui seul, empêcherait deux déploiements simultanés mais pas l'inversion : un run plus ancien dont l'image finit plus tard entrerait plus tard dans le groupe. Les deux sont utiles : le premier pour l'ordre, le second pour qu'aucun autre workflow ne déploie en même temps.
4. Votre organisation est au plan gratuit et ses dépôts sont privés. Listez quatre mesures qui réduisent le risque lié à la clé de déploiement, sans changer de plan.
Solution
- Une identité technique dédiée chez le fournisseur, avec une politique limitée à ce dont le pipeline a besoin (le registre seulement).
- Une date d'expiration courte et un renouvellement planifié.
- Le modèle GitOps : aucun identifiant du cluster dans GitHub.
- Une liste restreinte de personnes ayant le droit de pousser, et une revue des modifications de
.github/workflows/(un fichierCODEOWNERSles signale, même si la règle qui l'impose n'est pas disponible sur ce plan pour un dépôt privé). On peut ajouter un audit régulier des journaux d'utilisation de la clé chez le fournisseur.
5. Modifiez le workflow pour que le job production publie, dans le résumé du run, l'étiquette précédente et la nouvelle, et la commande de retour arrière à exécuter en cas de problème.
Solution
L'action ecrire-etiquette fournit déjà la sortie ancienne-valeur. Ajoutez après la poussée :
- name: Résumé du déploiement
if: steps.etiquette.outputs.modifie == 'true'
env:
AVANT: ${{ steps.etiquette.outputs.ancienne-valeur }}
APRES: ${{ steps.version.outputs.etiquette }}
run: |
{
echo "### Production : $AVANT vers $APRES"
echo
echo "Retour arrière : \`git revert $(git rev-parse --short HEAD)\` puis poussée sur main."
} >> "$GITHUB_STEP_SUMMARY"Le retour arrière d'un déploiement GitOps est un revert du commit de déploiement : Argo CD réapplique l'étiquette précédente.
Récapitulatif
- GitOps : le pipeline écrit l'étiquette dans Git, Argo CD déploie ; le pipeline n'a aucun accès au cluster.
- Les secrets sont chiffrés côté client (boîte scellée), illisibles une fois déposés, masqués dans les journaux tant qu'ils ne sont pas transformés.
- Un environnement apporte ses secrets, des règles de protection évaluées avant le runner (relecteurs, minuterie, branches et étiquettes) et un historique de déploiements.
- Le plan compte : sur un dépôt privé au plan gratuit, ni environnements ni approbations. Compensez par la portée et l'expiration des identifiants chez le fournisseur.
- Un groupe de concurrence par environnement, sans annulation ; une poussée qui se recale en cas de course.
- Calculez l'étiquette déployée avec la même règle que celle qui l'a produite.
Pour aller plus loin
- GitHub Docs, Deployments and environments : toutes les règles et leurs limites par plan.
- OpenGitOps : les quatre principes du GitOps.
- Leçon suivante : OIDC : des identités sans secret.
Sources
- GitHub Docs, Deployments and environments (règles de protection, secrets d'environnement, disponibilité par plan)
- GitHub Docs, Secrets (chiffrement par boîte scellée, masquage)
- GitHub Docs, Secrets reference (limites, noms)
- libsodium, Sealed boxes
- GitHub Changelog, pull_request_target and environment branch protections changes (7 novembre 2025)
- actions/create-github-app-token v3, action.yml (client-id)
- OpenGitOps, principes GitOps v1.0.0
- Argo CD, documentation : synchronisation automatique
- Scaleway, IAM : clés d'API et applications