# Même tag, aucun déploiement : pourquoi `cdk deploy` avec une image inchangée ne fait rien, et le script de 300 lignes que nous avons écrit autour

Deux histoires sur le même pipeline de déploiement, racontées ensemble parce que la seconde existe à cause de la première.

La première est un bug qui n'en est pas un : nous avons reconstruit une image de conteneur, nous l'avons poussée sous le même tag, nous avons lancé `cdk deploy`, nous l'avons vu annoncer *no changes*, et nous avons passé une heure à comprendre pourquoi la production faisait encore tourner l'ancien code. La seconde est ce qui s'est passé quand notre CI a été indisponible un moment, pour des raisons de facturation hors de notre contrôle, et qu'il nous fallait un moyen de déployer depuis un portable plus sûr que celui par lequel [nous avions un jour supprimé trois services depuis un portable](/fr/blog/cloudformation-deleted-our-app-runner-services).

## Première partie : CloudFormation compare des chaînes, pas des octets

Le service App Runner est une ressource CloudFormation, et l'image qu'il exécute est une propriété de type chaîne : `ImageIdentifier: 123456.dkr.ecr.us-east-1.amazonaws.com/api:abc1234`. Quand vous lancez `cdk deploy`, CloudFormation compare le nouveau template au dernier qu'il a appliqué. Si la chaîne est identique, il n'y a pas de diff, pas de mise à jour, et App Runner continue de servir ce qu'il a tiré la dernière fois.

C'est un comportement correct, et il mord dans exactement une situation : le tag est le même mais l'image derrière ne l'est pas. Nous l'avons rencontré en reconstruisant le même commit avec un argument de build modifié. Même SHA git, même tag `api:abc1234`, des octets différents dans ECR, et CloudFormation, correctement, n'a rien vu à faire. Le tag du registre pointait maintenant vers un nouveau digest ; le service tournait encore sur l'ancien ; rien nulle part n'était faux, sauf notre attente.

<div class="article-figure">
<svg viewBox="0 0 900 230" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Deux lignes. En haut, épinglage par tag : le tag ECR api:abc1234 passe du digest sha256:1111 à sha256:2222 par un rebuild ; le template dit toujours api:abc1234, CloudFormation ne voit aucun diff, App Runner continue d'exécuter sha256:1111. En bas, épinglage par digest : le script de déploiement résout le tag en sha256:2222 et met le digest dans le template ; CloudFormation voit un diff et App Runner déploie sha256:2222.">
<g font-family="Inter,system-ui,sans-serif" font-size="12">
<text x="20" y="26" fill="#ff6b8a" font-size="14" font-weight="700">épinglage par tag</text>
<rect x="20" y="40" width="200" height="56" rx="10" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="120" y="62" text-anchor="middle" fill="#f1f3ff">tag ECR api:abc1234</text><text x="120" y="82" text-anchor="middle" fill="#9aa3c7" font-size="11">sha256:1111 → rebuild → sha256:2222</text>
<rect x="260" y="40" width="200" height="56" rx="10" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="360" y="62" text-anchor="middle" fill="#f1f3ff">template</text><text x="360" y="82" text-anchor="middle" fill="#9aa3c7" font-size="11">ImageIdentifier: …/api:abc1234</text>
<rect x="500" y="40" width="170" height="56" rx="10" fill="#151b2e" stroke="#ff6b8a" stroke-width="1.5"/><text x="585" y="62" text-anchor="middle" fill="#ff6b8a" font-weight="700">aucun diff</text><text x="585" y="82" text-anchor="middle" fill="#9aa3c7" font-size="11">même chaîne que la dernière fois</text>
<rect x="710" y="40" width="175" height="56" rx="10" fill="#151b2e" stroke="#ff6b8a" stroke-width="1.5"/><text x="797" y="62" text-anchor="middle" fill="#f1f3ff">App Runner</text><text x="797" y="82" text-anchor="middle" fill="#ff6b8a" font-size="11">exécute toujours sha256:1111</text>
<text x="20" y="136" fill="#4fffb0" font-size="14" font-weight="700">épinglage par digest</text>
<rect x="20" y="150" width="200" height="56" rx="10" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="120" y="172" text-anchor="middle" fill="#f1f3ff">script de déploiement</text><text x="120" y="192" text-anchor="middle" fill="#9aa3c7" font-size="11">résout tag → sha256:2222</text>
<rect x="260" y="150" width="200" height="56" rx="10" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="360" y="172" text-anchor="middle" fill="#f1f3ff">template</text><text x="360" y="192" text-anchor="middle" fill="#9aa3c7" font-size="11">ImageIdentifier: …/api@sha256:2222</text>
<rect x="500" y="150" width="170" height="56" rx="10" fill="#151b2e" stroke="#4fffb0" stroke-width="1.5"/><text x="585" y="172" text-anchor="middle" fill="#4fffb0" font-weight="700">diff</text><text x="585" y="192" text-anchor="middle" fill="#9aa3c7" font-size="11">le digest a changé</text>
<rect x="710" y="150" width="175" height="56" rx="10" fill="#151b2e" stroke="#4fffb0" stroke-width="1.5"/><text x="797" y="172" text-anchor="middle" fill="#f1f3ff">App Runner</text><text x="797" y="192" text-anchor="middle" fill="#4fffb0" font-size="11">déploie sha256:2222</text>
</g>
</svg>
</div>

Il y a deux solutions honnêtes. La première est une règle : chaque déploiement fait avancer le SHA. Si vous voulez une image différente, commitez quelque chose. C'est simple, c'est ce que nous avons fait un moment, et ça échoue dès que quelqu'un doit reconstruire une image pour une raison qui n'implique pas de changement de code, comme un correctif de sécurité de l'image de base.

La seconde est d'épingler par digest. Après le push de l'image, le script de déploiement demande à ECR vers quoi le tag se résout et passe le digest, pas le tag, dans le template :

```bash
digest=$(aws ecr describe-images --repository-name api --image-ids imageTag="$TAG" \
  --query 'imageDetails[0].imageDigest' --output text)
npx cdk deploy platform-prod --context apiImage="$REPO/api@$digest"
```

```ts
// dans le stack
imageRepository: { imageIdentifier: this.node.tryGetContext('apiImage'), imageRepositoryType: 'ECR' }
```

Maintenant le template change chaque fois que les octets changent, et seulement à ce moment-là. Un rebuild du même commit avec un contenu différent est un vrai déploiement. Une relance avec un contenu identique est un vrai no-op. Le comportement de CloudFormation n'a pas changé ; nous lui avons simplement donné la chaîne qui signifie réellement « l'image ».

Le digest apparaît dans la console CloudFormation et dans `cdk diff` comme un hash illisible de 64 caractères. Nous affichons `tag → digest` dans la sortie du déploiement pour qu'un humain puisse encore dire ce qui part.

## Deuxième partie : déployer depuis un portable, en sécurité

La CI a disparu un moment. La plateforme avait toujours besoin de déploiements. Nous avions déjà appris, à prix fort, que `cdk deploy` depuis la machine d'un développeur avec le mauvais contexte supprime des ressources de production, donc « lance-le en local » n'était pas une option que nous accepterions à nouveau. L'alternative était un script dont le rôle est de faire qu'un déploiement depuis un portable se comporte comme un déploiement CI : mêmes entrées, mêmes garde-fous, aucun moyen de livrer ce qui n'est pas dans git.

Il a grandi jusqu'à environ 300 lignes de bash. Voici ce que font ces lignes, dans l'ordre.

<div class="article-figure">
<svg viewBox="0 0 900 250" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Pipeline du script de déploiement en huit étapes : 1 le preflight refuse un arbre sale, la mauvaise branche ou l'absence de --yes ; 2 un git worktree est extrait à origin/main ; 3 un git archive de cet arbre est envoyé vers S3 ; 4 trois jobs CodeBuild construisent les images en parallèle depuis l'archive ; 5 les tags sont résolus en digests ; 6 cdk diff est affiché et doit être confirmé ; 7 cdk deploy s'exécute avec les digests en contexte ; 8 vérification post-déploiement de la santé, du SHA de release et du bundle client, puis un message sur le canal de l'équipe.">
<defs><marker id="arrDs" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#4fffb0"/></marker></defs>
<g font-family="Inter,system-ui,sans-serif" font-size="11">
<rect x="15" y="30" width="200" height="60" rx="10" fill="#151b2e" stroke="#ff6b8a" stroke-width="1.5"/><text x="115" y="52" text-anchor="middle" fill="#ff6b8a" font-weight="700">1 · preflight</text><text x="115" y="70" text-anchor="middle" fill="#9aa3c7">arbre sale ? mauvaise branche ? pas de --yes ? → exit</text>
<line x1="217" y1="60" x2="240" y2="60" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrDs)"/>
<rect x="243" y="30" width="200" height="60" rx="10" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="343" y="52" text-anchor="middle" fill="#f1f3ff" font-weight="700">2 · worktree @ origin/main</text><text x="343" y="70" text-anchor="middle" fill="#9aa3c7">jamais la copie de travail</text>
<line x1="445" y1="60" x2="468" y2="60" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrDs)"/>
<rect x="471" y="30" width="200" height="60" rx="10" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="571" y="52" text-anchor="middle" fill="#f1f3ff" font-weight="700">3 · git archive → S3</text><text x="571" y="70" text-anchor="middle" fill="#9aa3c7">exactement les octets que voit CodeBuild</text>
<line x1="673" y1="60" x2="696" y2="60" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrDs)"/>
<rect x="699" y="30" width="185" height="60" rx="10" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="791" y="52" text-anchor="middle" fill="#f1f3ff" font-weight="700">4 · 3 × CodeBuild</text><text x="791" y="70" text-anchor="middle" fill="#9aa3c7">en parallèle · ~3 min</text>
<path d="M791,92 L791,120 L115,120 L115,148" fill="none" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrDs)"/>
<rect x="15" y="150" width="200" height="60" rx="10" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="115" y="172" text-anchor="middle" fill="#f1f3ff" font-weight="700">5 · tags → digests</text><text x="115" y="190" text-anchor="middle" fill="#9aa3c7">affichés pour l'humain</text>
<line x1="217" y1="180" x2="240" y2="180" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrDs)"/>
<rect x="243" y="150" width="200" height="60" rx="10" fill="#151b2e" stroke="#ffd166" stroke-width="1.5"/><text x="343" y="172" text-anchor="middle" fill="#ffd166" font-weight="700">6 · cdk diff, confirmer</text><text x="343" y="190" text-anchor="middle" fill="#9aa3c7">toute suppression → stop et lecture</text>
<line x1="445" y1="180" x2="468" y2="180" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrDs)"/>
<rect x="471" y="150" width="200" height="60" rx="10" fill="#151b2e" stroke="#4fffb0" stroke-width="1.5"/><text x="571" y="172" text-anchor="middle" fill="#f1f3ff" font-weight="700">7 · cdk deploy</text><text x="571" y="190" text-anchor="middle" fill="#9aa3c7">les digests en contexte</text>
<line x1="673" y1="180" x2="696" y2="180" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrDs)"/>
<rect x="699" y="150" width="185" height="60" rx="10" fill="#151b2e" stroke="#4fffb0" stroke-width="1.5"/><text x="791" y="172" text-anchor="middle" fill="#f1f3ff" font-weight="700">8 · vérifier + notifier</text><text x="791" y="190" text-anchor="middle" fill="#9aa3c7">santé · SHA · grep du bundle</text>
<text x="450" y="238" text-anchor="middle" fill="#9aa3c7">Un déploiement depuis un portable et un déploiement CI exécutent les mêmes étapes sur les mêmes octets. Le portable a juste un humain à l'étape 6.</text>
</g>
</svg>
</div>

**Preflight.** Le script refuse de s'exécuter si l'arbre de travail a des changements non commités, si `--env prod` est donné sans `--yes`, si la branche courante n'est pas `main` pour un déploiement de prod, ou si l'identité AWS qu'il s'apprête à utiliser n'est pas le rôle de déploiement attendu pour ce compte. Chaque refus affiche une phrase disant quoi faire. Environ 40 lignes, et les 40 les plus précieuses du fichier.

**Worktree.** Il crée un `git worktree` détaché à `origin/main` (après un `git fetch`) dans un répertoire temporaire et fait tout depuis là. Quoi qu'il y ait dans la copie de travail du développeur, en stash, à moitié édité ou expérimental, ça ne peut pas partir. Le SHA de ce worktree est l'identifiant de release pour tout ce qui suit.

**Archive vers S3.** `git archive` du worktree, envoyé dans un bucket de build sous le SHA. CodeBuild construit depuis cette archive, pas depuis une connexion GitHub, donc l'entrée du build est les mêmes octets qu'elle soit déclenchée par la CI ou par le script.

**Trois builds en parallèle.** Un projet CodeBuild par application, lancés ensemble, surveillés ensemble. Construire trois images Next.js sur le Docker d'un portable prendrait huit minutes et chaufferait la pièce ; sur trois instances CodeBuild, ça en prend trois. Les projets sont les mêmes que ceux qu'utilise la CI.

**Digests.** Comme dans la première partie. Le script résout chaque tag et affiche un tableau `app → tag → digest`.

**Diff et confirmation.** `cdk diff` contre l'environnement cible, affiché en entier. Si le diff contient une suppression de ressource, le script la met en évidence et exige que l'opérateur tape le nom du stack pour continuer, pas juste `y`. C'est l'étape qui aurait empêché l'incident.

**Déploiement.** `cdk deploy` avec les digests en contexte et `--require-approval never`, parce que l'approbation a déjà eu lieu à l'étape précédente, avec une meilleure information que celle que donne l'invite propre de CDK.

**Vérifier et notifier.** Frapper la route de santé jusqu'à ce qu'elle rapporte le nouveau SHA, [grepper le bundle client pour le même SHA](/fr/blog/nextjs-build-time-vs-runtime-config-on-containers), et poster un message sur le canal de l'équipe avec l'environnement, le SHA, le nom de l'opérateur et le résumé du diff. Si la vérification échoue, le script le dit bruyamment et sort avec un code non nul ; il ne revient pas en arrière automatiquement, parce qu'une vérification échouée signifie généralement « regarde », pas « annule ».

## À quoi ça ressemble à l'usage

```bash
./scripts/deploy.sh --env staging
./scripts/deploy.sh --env prod --yes
./scripts/deploy.sh --env prod --dry-run     # synth + diff, pas de build, pas de déploiement
```

Trois drapeaux. Tout le reste est décidé par le script à partir de git et du compte. Il n'y a aucun moyen de passer une image personnalisée, une branche personnalisée ou une valeur de contexte personnalisée, volontairement.

## Le réécririons-nous ?

Oui, et nous l'écririons dès le premier jour plutôt qu'après l'incident et la panne de CI. Non parce que les déploiements depuis un portable sont bons, mais parce que *le script est le pipeline*. Quand la CI est revenue, son workflow est devenu : checkout, assumer le rôle OIDC, lancer `./scripts/deploy.sh --env prod --yes`. Un seul chemin de code, exercé depuis les deux endroits, avec les garde-fous dans le code plutôt que dans un fichier YAML qui ne tourne que sur la machine de quelqu'un d'autre.

Les 300 lignes, c'est long pour un script shell. Elles ont remplacé une page de runbook qui disait « faites attention », ce qui est plus court et ne fonctionne pas.

Si votre déploiement est un `cdk deploy` que quelqu'un lance d'où il se trouve, [nous pouvons vous aider à le rendre ennuyeux](/contact).
