From 33aee5527bbf4ab0ea898bf2e543093e6a161bf3 Mon Sep 17 00:00:00 2001 From: Kylian Bardini Date: Tue, 3 Nov 2026 00:47:17 +0100 Subject: [PATCH 1/5] =?UTF-8?q?=F0=9F=90=9B=20fix(deploy):=20un=20push=20d?= =?UTF-8?q?e=20release=20sur=20main=20laisse=20la=20prod=20au=20tag=20(#13?= =?UTF-8?q?9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le merge dev → main déployait :main en prod, puis le tag déployait :vX.Y.Z une minute plus tard : deux swaps pour une release, le premier avant les gates de release.yml, et deux builds du même commit dont les chunks diffèrent (12 sur la v2.1.0). Un push sur main qui change la version de package.json construit toujours :main, mais ne déploie plus : la prod appartient au tag. Les pushes sans bump gardent le filet, et dans le doute (before nul ou introuvable, version absente) on déploie comme avant. La règle vit dans scripts/resolve-main-push.sh pour être rejouable hors CI. refs #139 --- .github/workflows/deploy.yml | 45 +++++++++++++++++++++++++++++++---- scripts/resolve-main-push.sh | 46 ++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 5 deletions(-) create mode 100755 scripts/resolve-main-push.sh diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 920d0cc..7c707df 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -40,6 +40,9 @@ jobs: env: ${{ steps.set.outputs.env }} image_tag: ${{ steps.set.outputs.image_tag }} rollback: ${{ steps.set.outputs.rollback }} + # `false` seulement pour un push sur main qui bumpe la version ; vide + # partout ailleurs, ce qui déploie (fail open) + deploy: ${{ steps.main.outputs.deploy }} steps: - id: set run: | @@ -56,6 +59,36 @@ jobs: echo "image_tag=$TAG" >> $GITHUB_OUTPUT echo "rollback=$ROLLBACK" >> $GITHUB_OUTPUT + # Un push sur main qui bumpe la version est un merge de release (ou un + # hotfix bumpé) : la prod appartient à son tag, que release.yml ne + # déploie qu'après ses gates (version = tag, notes, ancêtre de main). + # Déployer ici enverrait le même code en prod avant elles, avec un autre + # build : deux swaps pour une release, et des chunks du premier absents + # du second (#139). L'image :main est construite quand même. + # fetch-depth: 0 : le script lit package.json au commit `before`. + - if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - id: main + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + env: + BEFORE: ${{ github.event.before }} + run: ./scripts/resolve-main-push.sh "$BEFORE" "$GITHUB_SHA" >> "$GITHUB_OUTPUT" + + - name: Summary + if: steps.main.outputs.deploy == 'false' + env: + FROM: ${{ steps.main.outputs.from }} + TO: ${{ steps.main.outputs.to }} + run: | + { + echo "## Deploy prod laissé au tag" + echo "- \`package.json\` passe de \`$FROM\` à \`$TO\` : merge de release" + echo "- l'image \`:main\` est construite, la prod sera déployée par le tag \`v$TO\` (release.yml)" + } >> "$GITHUB_STEP_SUMMARY" + build: name: Build & push image if: needs.resolve.outputs.rollback != 'true' @@ -109,14 +142,16 @@ jobs: deploy: name: Deploy ${{ needs.resolve.outputs.env }} on VPS needs: [resolve, build] - if: always() && (needs.build.result == 'success' || needs.resolve.outputs.rollback == 'true') + if: always() && needs.resolve.outputs.deploy != 'false' && (needs.build.result == 'success' || needs.resolve.outputs.rollback == 'true') runs-on: ubuntu-latest timeout-minutes: 10 # Clé partagée avec le job deploy de release.yml (les groupes de - # concurrence sont globaux au repo) : le merge dev → main déclenche ce - # workflow et le push du tag qui suit déclenche l'autre, tous deux vers - # prod. Sans cette sérialisation, deux `deploy.sh --env prod` manipulent en - # même temps les mêmes conteneurs blue/green et le même upstream nginx. + # concurrence sont globaux au repo). Un merge de release ne déploie plus la + # prod d'ici, mais les deux workflows visent encore le même environnement : + # push dev puis tag rc vers preprod, push main sans bump, workflow_dispatch + # ou rerun d'un tag vers prod. Sans cette sérialisation, deux `deploy.sh` + # manipulent en même temps les mêmes conteneurs blue/green et le même + # upstream nginx. concurrency: group: vps-deploy-${{ needs.resolve.outputs.env }} cancel-in-progress: false diff --git a/scripts/resolve-main-push.sh b/scripts/resolve-main-push.sh new file mode 100755 index 0000000..1a04d6c --- /dev/null +++ b/scripts/resolve-main-push.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# Décide si un push sur `main` déploie la prod (le filet de deploy.yml) ou la +# laisse au tag. Émet des lignes `clé=valeur` au format de $GITHUB_OUTPUT. +# +# version inchangée entre et → deploy=true (filet) +# version changée → deploy=false (le tag déploie) +# +# Un push qui change la version de package.json est un merge de release, ou un +# hotfix bumpé : son tag vX.Y.Z suit, et release.yml déploie après ses gates +# (version = tag, notes présentes, ancêtre de main). Le filet enverrait le même +# code en prod avant elles, avec un build différent : deux swaps pour une +# release, et des chunks du premier absents du second (#139). +# +# Dans le doute, on déploie (fail open) : `before` nul (branche créée), +# introuvable (force-push) ou sans version lisible. +# +# Vit ici et pas inline dans deploy.yml pour être rejouable hors CI : +# `scripts/resolve-main-push.test.sh` en dépend. +# +# usage: resolve-main-push.sh [after-sha] (depuis le dépôt) +set -euo pipefail + +BEFORE="${1:-}" +AFTER="${2:-HEAD}" + +version_at() { + git show "$1:package.json" 2>/dev/null | jq -r '.version // empty' 2>/dev/null +} + +TO=$(version_at "$AFTER" || true) +FROM="" +if [[ -n "$BEFORE" && ! "$BEFORE" =~ ^0+$ ]]; then + FROM=$(version_at "$BEFORE" || true) +fi + +if [[ -n "$FROM" && -n "$TO" && "$FROM" != "$TO" ]]; then + DEPLOY=false +else + DEPLOY=true +fi + +cat < Date: Tue, 3 Nov 2026 00:47:25 +0100 Subject: [PATCH 2/5] =?UTF-8?q?=E2=9C=85=20test(ci):=20la=20d=C3=A9cision?= =?UTF-8?q?=20de=20d=C3=A9ploiement=20d'un=20push=20sur=20main=20(#139)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dépôt git jetable : un bump laisse la prod au tag, un push sans bump ou un bump de dépendance seul garde le filet, et chaque cas douteux (before nul, vide, introuvable, version absente) déploie. Une erreur dans un sens rejoue le double déploiement, dans l'autre elle coupe le filet. Lancé dans les Script checks de ci.yml, à côté de resolve-release-tag. refs #139 --- .github/workflows/ci.yml | 3 +- scripts/resolve-main-push.test.sh | 65 +++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) create mode 100755 scripts/resolve-main-push.test.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fd75225..b8e34ef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,7 +73,7 @@ jobs: # The checks of the logic no build exercises: the dither field, the hero scenes and their # OG colours (kept in step with theme.css), the panels' cameos, the activity feed, the # gitfut card the hero's easter egg reads, the article reader's voice and utterances, the - # release tag routing + # release tag routing, and which pushes to main deploy production - name: Script checks run: | bun scripts/dither.test.ts @@ -83,6 +83,7 @@ jobs: bun scripts/gitfut.test.ts bun scripts/speech.test.ts bash scripts/resolve-release-tag.test.sh + bash scripts/resolve-main-push.test.sh - name: Build run: bun run build diff --git a/scripts/resolve-main-push.test.sh b/scripts/resolve-main-push.test.sh new file mode 100755 index 0000000..ac067ae --- /dev/null +++ b/scripts/resolve-main-push.test.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# Check de resolve-main-push.sh : c'est lui qui décide si un push sur main +# déploie la prod. Une erreur dans un sens rejoue le double déploiement des +# releases (#139), dans l'autre elle coupe le filet des pushes sans bump. +# +# usage: scripts/resolve-main-push.test.sh +set -uo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +RESOLVE="$HERE/resolve-main-push.sh" +FAILED=0 + +REPO=$(mktemp -d) +trap 'rm -rf "$REPO"' EXIT +cd "$REPO" || exit 1 +git init -q +git config user.email check@example.invalid +git config user.name check + +# commit : commite ce package.json, affiche le sha +commit() { + printf '%s\n' "$1" > package.json + git add package.json + git commit -q --allow-empty -m x + git rev-parse HEAD +} + +# expect +expect() { + local got + got=$("$RESOLVE" "$2" "$3" 2>/dev/null | grep '^deploy=' | cut -d= -f2) + if [[ "$got" == "$4" ]]; then + echo " ok $1 → deploy=$got" + else + echo " FAIL $1 → attendu deploy=$4, obtenu '$got'" + FAILED=1 + fi +} + +A=$(commit '{"name":"x","version":"1.0.0"}') +B=$(commit '{"name":"x","version":"1.1.0"}') +C=$(commit '{"name":"x","version":"1.1.0","dependencies":{"nuxt":"4"}}') +D=$(commit '{"name":"x","version":"1.1.0","dependencies":{"nuxt":"4"}}') +UNVERSIONED=$(commit '{"name":"x"}') + +echo "bump de version → le tag déploie" +expect "merge de release" "$A" "$B" false +expect "push de plusieurs commits" "$A" "$D" false + +echo "version inchangée → filet" +expect "push sans bump" "$C" "$D" true +expect "bump de dépendance seul" "$B" "$C" true + +echo "dans le doute, on déploie" +expect "before nul (branche créée)" 0000000000000000000000000000000000000000 "$B" true +expect "before vide" "" "$B" true +expect "before introuvable (force-push)" deadbeefdeadbeefdeadbeefdeadbeefdeadbeef "$B" true +expect "version absente avant" "$UNVERSIONED" "$B" true +expect "version absente après" "$D" "$UNVERSIONED" true + +if [[ "$FAILED" -ne 0 ]]; then + echo "resolve-main-push: échecs" + exit 1 +fi +echo "resolve-main-push: tout passe" From 7bb0df343ba9f80b6a53206629f30a24928749d1 Mon Sep 17 00:00:00 2001 From: Kylian Bardini Date: Tue, 3 Nov 2026 00:47:31 +0100 Subject: [PATCH 3/5] =?UTF-8?q?=F0=9F=93=9D=20docs(release):=20le=20merge?= =?UTF-8?q?=20de=20release=20ne=20d=C3=A9ploie=20plus=20la=20prod=20(#139)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RELEASE.md : vue d'ensemble (le filet saute les pushes qui bumpent), la phrase « les deux ne se réveillent jamais ensemble », déjà fausse, la concurrence qui sert encore à d'autres chevauchements, la prod qui reste sur l'ancienne version si le job du tag échoue, et le « ne prouve pas » de /api/health. Même chose dans l'en-tête de release.yml et dans CLAUDE.md, et une entrée Fixed sous [Unreleased]. closes #139 --- .github/workflows/release.yml | 6 ++-- CHANGELOG.md | 12 ++++++++ CLAUDE.md | 6 ++-- docs/RELEASE.md | 54 +++++++++++++++++++++++------------ 4 files changed, 55 insertions(+), 23 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 328ff0e..013eee3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,8 +6,10 @@ name: Release # tag v1.1.0 → image :v1.1.0 → prod + release GitHub # # `deploy.yml` reste en place et garde l'itération quotidienne (push dev → -# preprod, push main → prod) : il déclenche sur `push: branches` et n'est donc -# jamais réveillé par un tag, les deux ne se marchent pas dessus. +# preprod, push main → prod). Il déclenche sur `push: branches`, jamais sur un +# tag, mais une release réveille les deux : le merge sur main, puis le tag. +# Pour ce merge, qui bumpe la version, deploy.yml construit :main sans +# déployer la prod : elle revient à ce workflow, après ses gates (#139). # # Un seul fichier pour les deux étages, là où gwm-cli sépare release.yml et # pre-release.yml : chez lui les matrices de build diffèrent (5 cibles, .deb, diff --git a/CHANGELOG.md b/CHANGELOG.md index 39e899c..fb5fbe2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,18 @@ migrates `[Unreleased]` into that file and empties this one, see ## [Unreleased] +### Fixed + +- **A release deployed production twice** + ([#139](https://github.com/kbrdn1/kbrdn.dev/issues/139)). The merge to + `main` shipped `:main` to prod, then the tag shipped `:vX.Y.Z` about a minute + later: two swaps, the first one before `release.yml`'s gates had run, and two + builds of the same commit whose chunks differ, so a visitor served by the + first could hit 404s on chunks the second no longer had. A push to `main` + that bumps the version now builds `:main` without deploying it: production + belongs to the tag. If the tag's deploy fails, production stays on the + previous version until `gh run rerun --failed`. + ## Past releases - [2.1.0](changelogs/2.1.0.md) · 2026-11-02 diff --git a/CLAUDE.md b/CLAUDE.md index 0155fc6..14c91fd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -139,14 +139,14 @@ Located in `.github/workflows/`: **deploy.yml** - Main deployment pipeline (build → push → SSH deploy): - Triggers on push to `main` (→ prod) and `dev` (→ preprod), ignoring `**.md` / `docs/` / `specs/` changes; `workflow_dispatch` supports a target env + rollback-only run -- `resolve` job: derives env (prod/preprod) and image tag (main/dev) from the ref +- `resolve` job: derives env (prod/preprod) and image tag (main/dev) from the ref. On a push to `main` that changes `package.json`'s version (a release merge, or a bumped hotfix), `scripts/resolve-main-push.sh` turns the deploy off: the image is still built, but prod belongs to the tag, which `release.yml` deploys only after its gates. Deploying here as well meant two swaps per release, with two builds whose chunks differ (#139). Any doubt (`before` null or unreadable, no version) deploys; `scripts/resolve-main-push.test.sh` covers it - `build` job: builds the multi-stage Docker image with buildx and pushes it to GHCR (`ghcr.io/:` + `:sha-`) - `deploy` job: SSHes into the VPS (key from `VPS_SSH_PRIVATE_KEY`) and runs the remote `deploy`/`rollback` wrapper, passing the image ref and runtime secrets (`GITHUB_TOKEN`, `RESEND_API_KEY`, `NUXT_STUDIO_TOKEN`, `NUXT_APP_SECRET`) inline; writes a GitHub step summary -Content changes (`app/content/**.mdx`) deploy through this same pipeline: `.mdx` is not covered by the `**.md` paths-ignore, so a content push to `main` triggers a full build + deploy. +Content changes (`app/content/**.mdx`) deploy through this same pipeline: `.mdx` is not covered by the `**.md` paths-ignore, so a content push to `main` triggers a full build + deploy (unless it bumps the version, see above). **release.yml** - Tag-driven release + deploy (see `docs/RELEASE.md`): -- Triggers on `push: tags` matching `v*.*.*`, so it never overlaps `deploy.yml` (which only watches branches); `workflow_dispatch` takes an explicit tag +- Triggers on `push: tags` matching `v*.*.*`; `workflow_dispatch` takes an explicit tag. A release still wakes both workflows (the merge to `main`, then the tag), so their deploy jobs share the `vps-deploy-` concurrency key; for that merge `deploy.yml` builds without deploying - `resolve` job: routes the tag via `scripts/resolve-release-tag.sh`: `vX.Y.Z-rc.N` → preprod + GitHub pre-release, `vX.Y.Z` → prod + release. Gates on `package.json` matching the tag and on the notes file existing, **before** anything deploys - `build` job: pushes an **immutable** `ghcr.io/:vX.Y.Z`, with `APP_VERSION` / `GIT_SHA` baked in as build args - `deploy` job: same SSH wrapper as `deploy.yml`, then verifies `/api/health` from the outside actually serves that version on that domain diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 0c4c170..6cdf6e9 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -9,7 +9,7 @@ seulement une release, **il déploie**. ``` push dev → image :dev → preprod itération quotidienne (deploy.yml) -push main → image :main → prod filet, cf. note plus bas +push main → image :main → prod filet, sauf si le push bumpe la version tag v1.1.0-rc.1 → image :v1.1.0-rc.1 → preprod candidat figé + pre-release GitHub tag v1.1.0 → image :v1.1.0 → prod release GitHub ``` @@ -18,8 +18,18 @@ Les images de tag sont **immutables** : `:dev` et `:main` désignent « le dernier », `:v1.1.0` désigne 1.1.0 pour toujours. C'est ce qui rend un retour arrière vers une version précise possible. -`deploy.yml` déclenche sur `push: branches`, `release.yml` sur `push: tags` : -les deux ne se réveillent jamais ensemble. +`deploy.yml` déclenche sur `push: branches`, `release.yml` sur `push: tags`, +mais une release réveille les deux : le merge `dev → main`, puis le tag. Ce +merge change la version de `package.json`, donc `deploy.yml` construit `:main` +**sans déployer la prod** : elle appartient au tag, qui ne déploie qu'après les +gates de `release.yml`. Le filet ne sert plus qu'aux pushes sur `main` sans +bump. La règle vit dans `scripts/resolve-main-push.sh`, couverte par son +`.test.sh`. + +Avant, le merge déployait aussi : deux swaps en prod pour une release, avec +deux builds dont les chunks diffèrent (le build n'est pas reproductible à +l'octet près, cf. #139), et un code en prod avant que les gates du tag l'aient +validé. ## Knobs du repo @@ -128,7 +138,9 @@ gh pr merge --merge --admin ``` ⚠️ `--admin` contourne aussi les checks requis : il mergerait une PR rouge, -que `deploy.yml` enverrait en prod. Il ne vient qu'après les 5 checks verts. +que le tag enverrait ensuite en prod (les gates de `release.yml` vérifient +version, notes et ascendance, pas la CI). Il ne vient qu'après les 5 checks +verts. `main` exige aussi une branche à jour (`strict`), ce que `dev` n'est jamais : chaque merge de release laisse sur `main` un commit de merge que `dev` ne @@ -150,10 +162,16 @@ tag. `release.yml` le vérifie : un tag stable qui n'est pas un ancêtre de `origin/main` est refusé avant tout build, donc un `vX.Y.Z` posé sur `dev` ne déploiera pas la production. -Les déploiements vers un même environnement sont sérialisés entre `deploy.yml` -et `release.yml` (clé de concurrence partagée) : le merge réveille le premier, -le tag le second, et sans ça les deux `deploy.sh --env prod` se disputeraient -les mêmes conteneurs. +Le merge ne déploie pas la prod (il bumpe la version, cf. la vue d'ensemble) : +c'est ce tag qui le fait. Si son job `Deploy prod` échoue, la prod **reste sur +la version précédente**, plus aucun filet ne l'y pousse quand même ; +rattrapage ci-dessous (`rerun --failed`). + +Les déploiements vers un même environnement restent sérialisés entre +`deploy.yml` et `release.yml` (clé de concurrence partagée) : les deux peuvent +encore viser la même cible (push `dev` puis tag rc vers preprod, push `main` +sans bump, `workflow_dispatch` ou rerun d'un tag vers prod), et sans ça deux +`deploy.sh` se disputeraient les mêmes conteneurs. Republier un tag existant (`workflow_dispatch`) **ne reconstruit pas** l'image : le tag de registre est mutable, mais l'artefact d'origine est conservé et @@ -185,13 +203,13 @@ Le workflow le fait déjà et échoue avant de publier la release si le compte n est pas ; cette commande sert à contrôler après coup, ou à répondre à « quelle version est live ». -⚠️ **Elle ne prouve pas que la release a tourné.** Le merge sur `main` déploie -aussi la prod par `deploy.yml` (avant ou après le job du tag, selon qui prend -le verrou `vps-deploy-prod`), et pour une stable l'image du filet porte la -même version que celle du tag (`APP_VERSION` vide hors release → repli sur -`package.json`, cf. `nuxt.config.ts`) ; le `sha` aussi est le même. Sur la -v1.0.1, `/api/health` renvoyait `1.0.1` en prod alors que `Deploy prod` avait -échoué. La preuve, c'est la release elle-même : le job `release` dépend de +⚠️ **Elle ne prouve pas à elle seule que la release a tourné.** Le merge ne +déploie plus la prod, mais un `workflow_dispatch` de `deploy.yml` vers prod +sert `:main`, qui porte la même version que le tag (`APP_VERSION` vide hors +release → repli sur `package.json`, cf. `nuxt.config.ts`) et le même `sha`. +Avant #139, le merge le faisait à chaque release : sur la v1.0.1, +`/api/health` renvoyait `1.0.1` en prod alors que `Deploy prod` avait échoué. +La preuve, c'est la release elle-même : le job `release` dépend de `deploy` (`needs: [resolve, deploy]`), qui fait le contrôle `/api/health` : une release publiée par le bot n'existe donc que si le déploiement a réussi, qu'il vienne du push du tag, d'un `rerun --failed` ou d'un `workflow_dispatch` (rangé @@ -231,9 +249,9 @@ version d'origine : refaire le `gh release edit` après. - **La release est un snapshot.** Corriger `changelogs/X.Y.Z.md` après coup ne la met pas à jour : `gh release edit vX.Y.Z --notes-file changelogs/X.Y.Z.md`. -- **`deploy.yml` a un `paths-ignore` sur `**.md` et `docs/**`.** Un merge de - release qui ne toucherait que du markdown ne redéploierait pas prod par ce - chemin : raison de plus pour que le déploiement de prod appartienne au tag. +- **`deploy.yml` a un `paths-ignore` sur `**.md` et `docs/**`.** Un push sur + `main` qui ne toucherait que du markdown ne redéploie pas la prod par ce + chemin. Sans effet sur une release : son déploiement appartient au tag. ## La chaîne de déploiement From 1ae1d6afcb6926a670b353c29d5c204abee1e872 Mon Sep 17 00:00:00 2001 From: Kylian Bardini Date: Tue, 3 Nov 2026 00:54:58 +0100 Subject: [PATCH 4/5] =?UTF-8?q?=F0=9F=90=9B=20fix(deploy):=20une=20version?= =?UTF-8?q?=20qui=20redescend=20garde=20le=20filet=20(#139)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `FROM != TO` traitait le revert d'une release (2.1.0 → 2.0.0) comme un bump : le filet sautait alors qu'aucun tag ne suit, le sien est déjà publié, et la prod restait sur la release fautive. Seule une montée de version laisse désormais la prod au tag, comparée avec `sort -V` pour que 1.10.0 passe devant 1.9.0. Trouvé par la review (axe standards). Test : revert et 1.10.0 → 1.9.0 gardent le filet, 1.9.0 → 1.10.0 le laisse au tag. refs #139 --- scripts/resolve-main-push.sh | 12 ++++++++---- scripts/resolve-main-push.test.sh | 8 +++++++- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/scripts/resolve-main-push.sh b/scripts/resolve-main-push.sh index 1a04d6c..1d10650 100755 --- a/scripts/resolve-main-push.sh +++ b/scripts/resolve-main-push.sh @@ -2,10 +2,10 @@ # Décide si un push sur `main` déploie la prod (le filet de deploy.yml) ou la # laisse au tag. Émet des lignes `clé=valeur` au format de $GITHUB_OUTPUT. # -# version inchangée entre et → deploy=true (filet) -# version changée → deploy=false (le tag déploie) +# version montée entre et → deploy=false (le tag déploie) +# version inchangée ou redescendue → deploy=true (filet) # -# Un push qui change la version de package.json est un merge de release, ou un +# Un push qui monte la version de package.json est un merge de release, ou un # hotfix bumpé : son tag vX.Y.Z suit, et release.yml déploie après ses gates # (version = tag, notes présentes, ancêtre de main). Le filet enverrait le même # code en prod avant elles, avec un build différent : deux swaps pour une @@ -33,7 +33,11 @@ if [[ -n "$BEFORE" && ! "$BEFORE" =~ ^0+$ ]]; then FROM=$(version_at "$BEFORE" || true) fi -if [[ -n "$FROM" && -n "$TO" && "$FROM" != "$TO" ]]; then +# Une version qui redescend (revert d'une release) n'a aucun tag à venir : son +# tag existe déjà, rien ne le repoussera. Seule une montée laisse la prod au +# tag ; `sort -V` pour que 1.10.0 passe bien devant 1.9.0. +if [[ -n "$FROM" && -n "$TO" && "$FROM" != "$TO" ]] && + [[ "$(printf '%s\n%s\n' "$FROM" "$TO" | sort -V | tail -n 1)" == "$TO" ]]; then DEPLOY=false else DEPLOY=true diff --git a/scripts/resolve-main-push.test.sh b/scripts/resolve-main-push.test.sh index ac067ae..34bf4a5 100755 --- a/scripts/resolve-main-push.test.sh +++ b/scripts/resolve-main-push.test.sh @@ -42,14 +42,20 @@ B=$(commit '{"name":"x","version":"1.1.0"}') C=$(commit '{"name":"x","version":"1.1.0","dependencies":{"nuxt":"4"}}') D=$(commit '{"name":"x","version":"1.1.0","dependencies":{"nuxt":"4"}}') UNVERSIONED=$(commit '{"name":"x"}') +REVERTED=$(commit '{"name":"x","version":"1.0.0"}') +V1_9=$(commit '{"name":"x","version":"1.9.0"}') +V1_10=$(commit '{"name":"x","version":"1.10.0"}') echo "bump de version → le tag déploie" expect "merge de release" "$A" "$B" false expect "push de plusieurs commits" "$A" "$D" false +expect "1.9.0 → 1.10.0 (ordre de version, pas lexical)" "$V1_9" "$V1_10" false -echo "version inchangée → filet" +echo "version inchangée ou redescendue → filet" expect "push sans bump" "$C" "$D" true expect "bump de dépendance seul" "$B" "$C" true +expect "revert d'une release (1.1.0 → 1.0.0)" "$D" "$REVERTED" true +expect "1.10.0 → 1.9.0" "$V1_10" "$V1_9" true echo "dans le doute, on déploie" expect "before nul (branche créée)" 0000000000000000000000000000000000000000 "$B" true From c89b3bf0c7cc28afbf4128597f67b469d38aa092 Mon Sep 17 00:00:00 2001 From: Kylian Bardini Date: Tue, 3 Nov 2026 00:54:58 +0100 Subject: [PATCH 5/5] =?UTF-8?q?=F0=9F=93=9D=20docs(release):=20la=20concur?= =?UTF-8?q?rence=20de=20release.yml=20et=20le=20cas=20du=20revert=20(#139)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le commentaire de concurrence du job deploy de release.yml justifiait encore la clé partagée par le double déploiement que #139 supprime ; il cite maintenant les chevauchements qui restent. RELEASE.md et CLAUDE.md disent « monte la version » et couvrent le revert, qui garde le filet. refs #139 --- .github/workflows/release.yml | 15 ++++++++------- CLAUDE.md | 2 +- docs/RELEASE.md | 5 +++-- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 013eee3..2f2d14d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -224,13 +224,14 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 # Clé partagée avec le job deploy de deploy.yml : les groupes de - # concurrence sont globaux au repo. Sans elle, le protocole normal met deux - # déploiements prod en vol simultanément : le merge dev → main réveille - # deploy.yml, le push du tag qui suit réveille release.yml, et leurs - # groupes au niveau workflow étant distincts, rien ne les sérialise. Les - # deux `deploy.sh --env prod` visent alors les mêmes noms de conteneurs et - # le même upstream nginx : le second `docker rm -f` celui du premier, et - # l'image active à l'arrivée est indéterminée. + # concurrence sont globaux au repo. Le merge d'une release ne déploie plus + # la prod depuis deploy.yml (#139), mais les deux workflows visent encore + # le même environnement : push dev puis tag rc vers preprod, push main + # sans bump ou workflow_dispatch pendant un tag vers prod. Leurs groupes au + # niveau workflow étant distincts, rien d'autre ne les sérialise. Deux + # `deploy.sh` en vol visent alors les mêmes noms de conteneurs et le même + # upstream nginx : le second `docker rm -f` celui du premier, et l'image + # active à l'arrivée est indéterminée. concurrency: group: vps-deploy-${{ needs.resolve.outputs.env }} cancel-in-progress: false diff --git a/CLAUDE.md b/CLAUDE.md index 14c91fd..37f7ea5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -139,7 +139,7 @@ Located in `.github/workflows/`: **deploy.yml** - Main deployment pipeline (build → push → SSH deploy): - Triggers on push to `main` (→ prod) and `dev` (→ preprod), ignoring `**.md` / `docs/` / `specs/` changes; `workflow_dispatch` supports a target env + rollback-only run -- `resolve` job: derives env (prod/preprod) and image tag (main/dev) from the ref. On a push to `main` that changes `package.json`'s version (a release merge, or a bumped hotfix), `scripts/resolve-main-push.sh` turns the deploy off: the image is still built, but prod belongs to the tag, which `release.yml` deploys only after its gates. Deploying here as well meant two swaps per release, with two builds whose chunks differ (#139). Any doubt (`before` null or unreadable, no version) deploys; `scripts/resolve-main-push.test.sh` covers it +- `resolve` job: derives env (prod/preprod) and image tag (main/dev) from the ref. On a push to `main` that raises `package.json`'s version (a release merge, or a bumped hotfix), `scripts/resolve-main-push.sh` turns the deploy off: the image is still built, but prod belongs to the tag, which `release.yml` deploys only after its gates. Deploying here as well meant two swaps per release, with two builds whose chunks differ (#139). A version that goes down (a reverted release) has no tag to come and deploys, as does any doubt (`before` null or unreadable, no version); `scripts/resolve-main-push.test.sh` covers it - `build` job: builds the multi-stage Docker image with buildx and pushes it to GHCR (`ghcr.io/:` + `:sha-`) - `deploy` job: SSHes into the VPS (key from `VPS_SSH_PRIVATE_KEY`) and runs the remote `deploy`/`rollback` wrapper, passing the image ref and runtime secrets (`GITHUB_TOKEN`, `RESEND_API_KEY`, `NUXT_STUDIO_TOKEN`, `NUXT_APP_SECRET`) inline; writes a GitHub step summary diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 6cdf6e9..c474da9 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -20,10 +20,11 @@ arrière vers une version précise possible. `deploy.yml` déclenche sur `push: branches`, `release.yml` sur `push: tags`, mais une release réveille les deux : le merge `dev → main`, puis le tag. Ce -merge change la version de `package.json`, donc `deploy.yml` construit `:main` +merge monte la version de `package.json`, donc `deploy.yml` construit `:main` **sans déployer la prod** : elle appartient au tag, qui ne déploie qu'après les gates de `release.yml`. Le filet ne sert plus qu'aux pushes sur `main` sans -bump. La règle vit dans `scripts/resolve-main-push.sh`, couverte par son +bump, ou dont la version redescend (le revert d'une release n'a aucun tag à +venir). La règle vit dans `scripts/resolve-main-push.sh`, couverte par son `.test.sh`. Avant, le merge déployait aussi : deux swaps en prod pour une release, avec