Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
45 changes: 40 additions & 5 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand All @@ -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'
Expand Down Expand Up @@ -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
Expand Down
21 changes: 12 additions & 9 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<repo>:<tag>` + `:sha-<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-<env>` 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/<repo>: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
Expand Down
55 changes: 37 additions & 18 deletions docs/RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand All @@ -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

Expand Down Expand Up @@ -128,7 +139,9 @@ gh pr merge <N> --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
Expand All @@ -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
Expand Down Expand Up @@ -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é
Expand Down Expand Up @@ -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

Expand Down
50 changes: 50 additions & 0 deletions scripts/resolve-main-push.sh
Original file line number Diff line number Diff line change
@@ -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 <before> et <after> → 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 <before-sha> [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 <<EOF
deploy=$DEPLOY
from=$FROM
to=$TO
EOF
71 changes: 71 additions & 0 deletions scripts/resolve-main-push.test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
#!/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 <json> : 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 <libellé> <before> <after> <deploy attendu>
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"
Loading