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/.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/.github/workflows/release.yml b/.github/workflows/release.yml index 328ff0e..2f2d14d 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, @@ -222,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/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..37f7ea5 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 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 -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..c474da9 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,19 @@ 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 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, 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 +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 +139,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 +163,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 +204,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 +250,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 diff --git a/scripts/resolve-main-push.sh b/scripts/resolve-main-push.sh new file mode 100755 index 0000000..1d10650 --- /dev/null +++ b/scripts/resolve-main-push.sh @@ -0,0 +1,50 @@ +#!/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 montée entre et → deploy=false (le tag déploie) +# version inchangée ou redescendue → deploy=true (filet) +# +# 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 +# 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 + +# 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 +fi + +cat < : 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"}') +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 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 +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"