Aller au contenu
CI/CD & Automatisation medium

Concurrency GitHub Actions : éviter les runs parallèles

15 min de lecture

Par défaut, GitHub Actions exécute chaque déclenchement indépendamment. Si vous poussez 5 commits rapidement, vous obtenez 5 workflows en parallèle. C'est du gaspillage de ressources et, pire, une source de conflits de déploiement. Le bloc concurrency règle les deux.

  • Annuler automatiquement les runs obsolètes avec cancel-in-progress
  • Définir un groupe de concurrence par branche, par PR ou par environnement
  • Sérialiser les déploiements pour éviter deux déploiements simultanés
  • Protéger la branche main des annulations intempestives
  • Éviter les pièges : groupe trop large, trop spécifique, ou dangereux

Sans garde-fou, chaque push lance son propre workflow. Sur une branche active, les premiers runs sont déjà obsolètes avant même de finir : seul le dernier commit compte.

Commit 1 → Workflow 1 (en cours)
Commit 2 → Workflow 2 (en cours) ← Inutile, commit 2 sera écrasé
Commit 3 → Workflow 3 (en cours) ← Inutile aussi
Commit 4 → Workflow 4 (en cours) ← Inutile aussi
Commit 5 → Workflow 5 (en cours) ← Seul celui-ci compte vraiment

Solution : annuler automatiquement les runs obsolètes.

Le bloc concurrency se déclare au niveau du workflow (ou d'un job). Deux propriétés suffisent : un groupe et la décision d'annuler ou non.

name: CI
on: [push, pull_request]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-24.04
steps: [...]

group accepte n'importe quelle chaîne, y compris des expressions ${{ }} évaluées au démarrage du run : c'est ce qui permet d'obtenir un groupe par branche ou par PR. cancel-in-progress est le seul commutateur de comportement, et il ne concerne que les runs déjà en cours du même groupe.

PropriétéDescription
groupIdentifiant du groupe de concurrence
cancel-in-progressAnnuler les runs en cours du même groupe

Tout se joue dans la composition du groupe. Deux runs qui partagent un groupe identique entrent en concurrence ; sinon ils s'ignorent. Voici les quatre découpages les plus utiles.

C'est le découpage à retenir par défaut. github.ref contient la référence complète (refs/heads/main), donc deux branches ne se retrouvent jamais dans le même groupe, et github.workflow évite qu'un workflow en annule un autre.

# Annule les runs précédents sur la même branche
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

Un push sur main annule les runs précédents sur main, mais pas ceux sur develop.

Ce groupe s'appuie sur le numéro de la PR plutôt que sur la branche. Utile quand le workflow réagit aussi à des événements de PR (réouverture, changement de cible) où github.ref ne désigne pas la même chose d'un événement à l'autre. Sur un run déclenché hors PR, l'expression est vide et tous ces runs tombent dans un groupe commun.

# Annule les runs précédents sur la même PR
concurrency:
group: pr-${{ github.event.pull_request.number }}
cancel-in-progress: true

Un groupe constant, sans expression, met en file toutes les exécutions du workflow quelle que soit la branche. C'est la configuration d'un déploiement : un seul run à la fois, et les suivants attendent au lieu d'être annulés.

# Un seul run de ce workflow à la fois (global)
concurrency:
group: deploy-production
cancel-in-progress: false # Attendre au lieu d'annuler

Ici le groupe dépend d'un paramètre du run : deux déploiements vers des environnements différents avancent en parallèle, deux déploiements vers le même environnement se sérialisent. L'expression || 'staging' fournit une valeur de repli quand le workflow n'est pas déclenché manuellement, sans quoi le groupe serait vide.

# Un seul déploiement par environnement
concurrency:
group: deploy-${{ github.event.inputs.environment || 'staging' }}
cancel-in-progress: false

Les quatre configurations qui suivent couvrent la quasi-totalité des besoins. Elles se distinguent par une seule question : le travail en cours est-il jetable parce qu'un commit plus récent le remplace, ou irremplaçable parce qu'il modifie un système ?

Annuler les runs obsolètes pour économiser les ressources : une compilation et des tests sont rejouables, rien n'est perdu à les interrompre. Sur un dépôt actif, ce seul bloc supprime l'essentiel des minutes gaspillées.

name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions: {}
jobs:
build:
runs-on: ubuntu-24.04
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- run: npm ci && npm test

Ne pas annuler, mais attendre que le déploiement précédent soit terminé : un script de déploiement coupé en plein vol laisse l'infrastructure dans un état intermédiaire, avec la moitié des instances à jour. La mise en file garantit qu'un seul déploiement écrit à la fois, et que le dernier commit passe en dernier.

name: Deploy
on:
push:
branches: [main]
concurrency:
group: deploy-production
cancel-in-progress: false # Attendre, ne pas annuler
permissions: {}
jobs:
deploy:
runs-on: ubuntu-24.04
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- run: ./deploy.sh

Un workflow qui teste puis déploie a besoin des deux comportements à la fois. La solution est de les déclarer à des niveaux différents : le bloc du workflow annule les runs obsolètes, celui du job deploy impose sa propre file d'attente. Le groupe du job reste indépendant de celui du run qui le contient.

name: CI and Deploy
on:
push:
branches: [main]
# Concurrence globale pour ce workflow
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
# Aucun droit par défaut
permissions: {}
jobs:
test:
runs-on: ubuntu-24.04
steps:
- run: npm test
deploy:
needs: test
runs-on: ubuntu-24.04
# Concurrence spécifique pour le déploiement
concurrency:
group: deploy-production
cancel-in-progress: false
steps:
- run: ./deploy.sh

Ne pas annuler les runs sur la branche principale : sur main, chaque commit mérite son propre résultat, ne serait-ce que pour savoir lequel a cassé la build. cancel-in-progress accepte une expression, ce qui permet d'annuler sur les branches de travail tout en laissant les runs de main aller au bout.

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
# Annuler uniquement sur les PRs
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}

La valeur de cancel-in-progress change radicalement ce qui arrive aux runs déjà lancés. Comparons les deux modes.

L'arrivée d'un nouveau run interrompt tous ceux du groupe, y compris ceux qui étaient presque terminés. Les jobs annulés apparaissent avec le statut cancelled et ne publient ni artefacts ni résultats de tests.

Workflow 1 (en cours) → ANNULÉ
Workflow 2 (en cours) → ANNULÉ
Workflow 3 (nouveau) → S'EXÉCUTE

Le run en cours va au bout quoi qu'il arrive, c'est précisément ce qu'on attend d'un déploiement ou d'une publication de paquet. Les runs suivants restent en attente sans mobiliser de runner tant que le groupe n'est pas libéré.

Workflow 1 (en cours) → SE TERMINE
Workflow 2 (en attente) → ATTEND
Workflow 3 (en attente) → ATTEND

Les workflows en attente s'exécutent dans l'ordre.

Vous pouvez aussi définir la concurrence au niveau d'un job spécifique : c'est le bon réglage quand un seul job du workflow touche un système partagé. Les autres jobs restent libres de tourner en parallèle, seul le job concerné est mis en file. Un job bloqué par son groupe attend sans consommer de runner, mais le run entier reste marqué en cours tant qu'il n'a pas démarré.

jobs:
test:
runs-on: ubuntu-24.04
# Pas de restriction de concurrence pour les tests
steps: [...]
deploy:
needs: test
runs-on: ubuntu-24.04
concurrency:
group: deploy-${{ github.ref }}
cancel-in-progress: false
steps: [...]

Les effets de concurrency se constatent après coup : un run disparaît, un autre reste en attente sans explication apparente. Deux réflexes suffisent à lever le doute, lire le motif d'annulation affiché par GitHub et faire imprimer par le workflow les valeurs qui composent son groupe.

Dans l'onglet Actions, les runs annulés affichent le statut "Cancelled" avec le message "This run was cancelled because a newer run was started".

Le bloc concurrency n'apparaît nulle part dans les logs : pour savoir quel groupe un run a réellement calculé, il faut afficher soi-même les valeurs de contexte qui le composent. C'est le moyen le plus direct de repérer une expression vide, cause classique d'annulations inattendues.

- name: Debug concurrency
env:
# Les valeurs de contexte passent par env:, jamais dans run: en clair
WORKFLOW: ${{ github.workflow }}
REF: ${{ github.ref }}
RUN_ID: ${{ github.run_id }}
RUN_ATTEMPT: ${{ github.run_attempt }}
run: |
echo "Workflow: $WORKFLOW"
echo "Ref: $REF"
echo "Run ID: $RUN_ID"
echo "Run attempt: $RUN_ATTEMPT"

Trois réglages de groupe se retournent contre vous. Voici comment les reconnaître et les corriger.

Un groupe constant met tous les workflows du dépôt en concurrence : le déclenchement d'une CI sur une branche annule la CI d'une autre branche, et les développeurs voient leurs runs disparaître sans raison visible.

# ❌ Tous les workflows sont dans le même groupe !
concurrency:
group: ci
cancel-in-progress: true
# ✅ Groupe par workflow ET branche
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

github.run_id est unique par exécution : chaque run se retrouve donc seul dans son groupe et n'entre jamais en concurrence avec un autre. Le bloc est bien présent, il ne sert simplement à rien, ce qui est plus difficile à repérer qu'une absence de configuration.

# ❌ Chaque run a son propre groupe (inutile)
concurrency:
group: ${{ github.run_id }}
# ✅ Groupe par branche
concurrency:
group: ${{ github.ref }}

C'est l'erreur la plus coûteuse : appliquer à un déploiement le réglage pensé pour la CI. Un terraform apply ou un kubectl apply interrompu en cours d'exécution laisse l'état partiellement appliqué, et rien ne le signale.

# ❌ Dangereux : peut annuler un déploiement en cours
concurrency:
group: deploy
cancel-in-progress: true
# ✅ Attendre le déploiement précédent
concurrency:
group: deploy
cancel-in-progress: false

Vérifiez que l'essentiel de ce guide est acquis. Les questions portent uniquement sur ce qui vient d'être expliqué ici.

Contrôle de connaissances

Validez vos connaissances avec ce quiz interactif

6 questions
6 min.
70% requis

Informations

  • Le chronomètre démarre au clic sur Démarrer
  • Questions à choix multiples, vrai/faux et réponses courtes
  • Vous pouvez naviguer entre les questions
  • Les résultats détaillés sont affichés à la fin

Lance le quiz et démarre le chronomètre

  • concurrency regroupe les runs et annule ou met en file ceux du même groupe.
  • Le groupe canonique est ${{ github.workflow }}-${{ github.ref }} : un groupe par workflow et par branche.
  • cancel-in-progress: true pour la CI (économiser des minutes), false pour les déploiements (ne jamais couper un déploiement en cours).
  • Un groupe trop large annule des runs sans rapport ; un groupe trop spécifique (github.run_id) n'annule jamais rien.
  • Protégez main en conditionnant l'annulation : cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}.

Pour la référence complète, consultez la documentation officielle sur la concurrency.

Ce site vous est utile ?

Sachez que moins de 1% des lecteurs soutiennent ce site.

Je maintiens +700 guides gratuits, sans pub ni tracking. Un soutien, même symbolique, m'aide à couvrir l'hébergement et à garder ces ressources gratuites. Merci pour votre appui.

Le formulaire ne s'affiche pas ? Ouvrir Ko-fi dans un onglet.

Abonnez-vous et suivez mon actualité DevSecOps sur LinkedIn