Aller au contenu

CI GitHub Actions durcie pour un projet Go

Une CI Go, ce n'est pas juste go test. Test multi-plateforme, lint, scan de vulnérabilités, et surtout un durcissement contre les attaques supply-chain sur les workflows eux-mêmes. Tout ce qui suit tourne sur push et pull_request, mesuré sur un vrai repo public : gopen.

Flow

Flow CI

Vue d'ensemble

Six workflows, un rôle par fichier. Le release (GoReleaser) est traité à part, voir l'article dédié.

Workflow Rôle Déclencheur
ci.yml go mod verify, go test -race, build matrice ubuntu/macos/windows push, PR
lint.yml golangci-lint v2 push, PR
govulncheck.yml CVE sur les dépendances et la toolchain push, PR
go-format.yml goimports, suggestions inline en PR push, PR
markdownlint.yml remark-lint sur les .md, suggestions inline push, PR
github-actions.yml zizmor, audit des workflows eux-mêmes push, PR

Le socle de durcissement

C'est ce qui distingue une CI correcte d'une CI qu'on peut laisser tourner sur un repo public sans y penser. Quatre patterns, appliqués sur tous les workflows.

Épingler les actions par SHA, pas par tag

Un tag Git est mutable. uses: actions/checkout@v7 pointe vers ce que le mainteneur (ou quelqu'un qui a compromis son compte) décide d'y mettre. Le SHA d'un commit, lui, ne bouge pas.

# Fragile : le tag peut être redéplacé sur du code malveillant
- uses: actions/checkout@v7

# Durci : le SHA est immuable, le commentaire garde la version lisible
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0

Le commentaire # v7.0.0 n'est pas décoratif : Renovate le lit pour bumper le SHA et mettre à jour le commentaire, ce qui garde le pinning sans figer les actions dans le passé. Sur gopen, les updates d'actions sont même groupées et automergées :

renovate.json
{
  "matchManagers": ["github-actions"],
  "groupName": "github-actions",
  "automerge": true,
  "automergeType": "pr"
}

Permissions au moindre privilège

Le GITHUB_TOKEN par défaut est trop permissif. On le réduit au strict nécessaire, par workflow, et on descend au niveau du job quand un seul job a besoin d'un droit en écriture.

# Aucun droit : le workflow zizmor n'écrit rien au niveau global...
permissions: {}

jobs:
  zizmor:
    permissions:
      security-events: write   # ...sauf ce job, pour publier dans l'onglet Security

L'échelle qu'on retrouve dans le repo :

Besoin permissions
Lecture seule (test, lint, govulncheck) contents: read
Commenter/suggérer en PR (reviewdog) contents: read + pull-requests: write
Rien au global, un droit scopé par job {} puis override dans le job

Couper les credentials sur le checkout

Par défaut, actions/checkout laisse le token traîner dans la config Git du runner. Un script de build compromis peut le récupérer et pousser sur le repo. On le désactive partout :

- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
  with:
    persist-credentials: false

zizmor : le linter de tes workflows

Les trois patterns ci-dessus, il faut les tenir sur la durée. zizmor audite les fichiers .github/workflows/ et flague les tags non épinglés, les permissions trop larges, les injections de template, les credentials persistés. Un workflow qui s'auto-surveille :

.github/workflows/github-actions.yml
name: github-actions

on:
  push:
    branches: [main, master]
  pull_request:

permissions: {}

jobs:
  zizmor:
    name: Run zizmor
    runs-on: ubuntu-latest
    permissions:
      security-events: write
    steps:
      - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
        with:
          persist-credentials: false
      - uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa # v0.5.7

Faire tourner zizmor en local

uvx zizmor .github/workflows/ (ou pipx run zizmor) donne le même verdict avant de committer.

Test et build multi-plateforme

Deux jobs dans ci.yml. Le premier teste, le second vérifie que le binaire compile sur les trois OS.

.github/workflows/ci.yml
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
        with:
          persist-credentials: false
      - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
        with:
          go-version-file: go.mod
      - run: go mod verify
      - run: go build -v ./...
      - run: go test -race ./...

  build:
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]
    steps:
      - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
        with:
          persist-credentials: false
      - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
        with:
          go-version-file: go.mod
      - run: go build -v -o gopen${{ matrix.os == 'windows-latest' && '.exe' || '' }} .

Trois choix qui comptent :

  • go-version-file: go.mod : la version de Go vient du go.mod, pas d'une valeur hardcodée qui dérive. Une seule source de vérité.
  • go test -race : le détecteur de data races coûte un peu de temps CPU mais attrape des bugs de concurrence qu'aucun test classique ne voit.
  • go mod verify : confirme que les modules téléchargés correspondent au hash du go.sum avant de compiler.

Pourquoi séparer test et build ?

Le job test tourne une fois sur Linux (rapide, avec -race). Le job build vérifie juste la compilation cross-OS. Inutile de rejouer toute la suite de tests trois fois quand seul le build est OS-dépendant.

Lint

golangci-lint agrège des dizaines de linters en un seul binaire, un seul passage. On épingle sa version pour que le CI soit reproductible : un nouveau linter activé par une mise à jour ne doit pas casser une PR sans qu'on l'ait décidé.

.github/workflows/lint.yml
- uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0
  with:
    version: v2.12.2

La config vit dans .golangci.yml. Le principe : partir du set standard, ajouter ce qui apporte de la valeur, et justifier chaque exclusion en commentaire pour que le prochain qui lit comprenne pourquoi.

.golangci.yml
version: "2"

linters:
  default: standard   # govet, staticcheck, errcheck, ineffassign, unused
  enable:
    - bodyclose        # response body non fermé = fuite de connexion
    - errorlint        # comparaisons d'erreurs cassées par le wrapping %w
    - forcetypeassert  # type assertion sans le ok, => panic potentiel
    - gosec            # scan sécurité
    - misspell
    - perfsprint       # fmt.Sprintf remplaçable par plus rapide
    - revive
  settings:
    gosec:
      excludes:
        - G204 # subprocess avec variable : gopen exec git/open sans shell,
        #        les args passent en argv séparés, pas d'injection possible
  exclusions:
    rules:
      # gosec sur les tests est bruyant (perms de fichiers) et hors runtime
      - path: _test\.go
        linters:
          - gosec

Ne pas désactiver un linter pour faire passer le CI

Une exclusion sans commentaire, c'est de la dette. Soit le linter a raison et on corrige, soit il a tort dans ce contexte précis et on écrit pourquoi. G204 ci-dessus est un cas légitime : le binaire n'invoque jamais de shell.

gocritic et les gros structs copiés

Le set standard ne dit rien des copies de structs. Sur un projet qui manipule des objets Kubernetes, ça compte : un corev1.Pod pèse 1192 octets, un Node 768, un Container 408 — mesuré au unsafe.Sizeof. Un for _, p := range pods copie donc 1,2 ko par tour, et un helper qui prend un corev1.Pod en paramètre le recopie à chaque appel.

Le tag performance de gocritic attrape exactement ça, via rangeValCopy et hugeParam. Il n'est pas activé par défaut :

.golangci.yml
linters:
  enable:
    - gocritic
  settings:
    gocritic:
      enabled-tags:
        - performance
      settings:
        hugeParam:
          # Défaut 80 : flague aussi les structs de config qu'on passe
          # volontairement par valeur et qu'on copie une fois par process.
          # 256 garde le check braqué sur les objets d'API.
          sizeThreshold: 256

Le seuil est le réglage à ne pas laisser par défaut. À 80, le linter réclame aussi des pointeurs sur les structs de configuration qui traversent toutes les signatures : sur klens, un Flags de 104 octets et un App de 96. Or elles sont copiées une fois par exécution. Les passer en pointeur touchait vingt-cinq signatures pour zéro gain, en ouvrant au passage la porte à une mutation accidentelle de flags partagés.

Reste le plus intéressant : corriger les trente sites trouvés sur klens n'a rien accéléré. Mesuré sur un cluster de production de 6400 pods, cinq exécutions, médianes :

Commande Temps RSS max
privileged -A 3,59 s → 3,53 s 354 → 356 MiB
images -A 3,31 s → 3,59 s 372 → 372 MiB

Tout dans le bruit. Le calcul de départ, « 56 Mo recopiés par exécution », était juste et hors sujet : 56 Mo de memcpy, c'est quelques millisecondes face aux 3,4 s passées à attendre l'apiserver. Et ces copies vivent sur la pile puis sont réutilisées, donc elles n'apparaissent même pas dans le RSS de pointe, dominé par le graphe d'objets décodés.

Ce que le check apporte quand même : il empêche de réintroduire le motif là où il coûterait vraiment, dans une slice de Pod à durée de vie longue ou une boucle imbriquée.

Un finding de linter n'est pas un gain de perf

hugeParam et rangeValCopy disent qu'une copie existe, pas qu'elle coûte. Sur un binaire dont le temps part en attente réseau, la réponse est souvent « rien du tout ». On mesure avant d'annoncer un gain, et quand le gain n'existe pas, le commit se type chore, pas perf.

Le piège des findings masqués

Celui-là vaut pour tous les linters, pas seulement gocritic. Par défaut, golangci-lint cache une partie de ce qu'il trouve : 50 findings par linter, et surtout 3 occurrences par message identique.

Trente sites copiant chacun 1192 bytes produisent trente messages au texte identique, dont trois s'affichent. On corrige les trois, on relance, trois nouveaux apparaissent. Sur klens, le premier passage annonçait 25 problèmes : des fichiers entiers n'étaient jamais sortis, et la correction paraissait finie alors qu'il en restait.

.golangci.yml
issues:
  # Les défauts (50 par linter, 3 par message identique) masquent les répétitions :
  # un lot de findings au texte identique paraît réglé alors qu'il en reste.
  max-issues-per-linter: 0
  max-same-issues: 0

Sur un repo de taille raisonnable, il n'y a aucune raison de plafonner. Un linter qui ne montre pas tout est plus dangereux qu'un linter bruyant : on lui fait confiance à tort.

Vulnérabilités des dépendances

govulncheck est l'outil officiel de la Go Team. Sa force : il ne signale une CVE que si ton code appelle réellement la fonction vulnérable, pas juste parce que le module est dans l'arbre de dépendances. Beaucoup moins de faux positifs qu'un scanner générique.

.github/workflows/govulncheck.yml
permissions:
  contents: read

jobs:
  govulncheck:
    runs-on: ubuntu-latest
    steps:
      - uses: golang/govulncheck-action@032d45514ae346b1db93c04b0c90b841c370344f # v1.1.0
        with:
          go-version-file: go.mod

Il couvre aussi les vulnérabilités de la toolchain Go elle-même, pas seulement les libs tierces.

Feedback direct dans la PR

Faire échouer un job, c'est bien. Dire quoi corriger et où, c'est mieux. reviewdog poste les problèmes en suggestions inline sur la PR, applicables en un clic.

.github/workflows/go-format.yml
permissions:
  contents: read
  pull-requests: write   # requis pour poster les suggestions

jobs:
  goimports:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
        with:
          persist-credentials: false
      - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
        with:
          go-version-file: go.mod
      - run: go install golang.org/x/tools/cmd/goimports@latest
      - run: goimports -w $(find . -name '*.go')
      - uses: reviewdog/action-suggester@2558ba17e65a9039e73764a73009fc05fef28a46 # v1.24.3
        with:
          tool_name: goimports

Le job reformate le code, puis reviewdog compare avec ce qui a été poussé et propose le diff en commentaire. Même mécanique pour le Markdown avec reviewdog/action-markdownlint et reporter: github-pr-review.

Automerge et releases pilotées par les commits

Une CI fiable et reproductible débloque un cran d'automatisation de plus : laisser Renovate merger ses PR tout seul, et faire du type de commit le déclencheur d'une release. L'automerge n'est sûr que parce que tout ce qui précède existe — il n'avale une PR qu'après une CI verte.

Élargir l'automerge

Plus haut, seules les mises à jour d'actions étaient automergées. On peut étendre la règle à toutes les updates sûres — minor, patch, digest — et ne garder en revue manuelle que les major, les seules à casser une API :

renovate.json
{
  "matchUpdateTypes": ["minor", "patch", "digest"],
  "automerge": true,
  "automergeType": "pr"
}

automergeType: pr ouvre quand même une PR et attend les checks : l'automerge ne court-circuite pas la CI, il retire juste le clic humain une fois qu'elle est verte.

Faire du commit le déclencheur de version

Le chaînon manquant : relier le contenu d'une PR mergée à un numéro de version. Les commits conventionnels le permettent — feat → bump mineur, fix → bump patch. Renovate sait taguer ses commits par type d'update, ce qui suffit à décider du bump :

renovate.json
"packageRules": [
  {
    "matchManagers": ["gomod"],
    "matchUpdateTypes": ["minor"],
    "semanticCommitType": "feat"
  },
  {
    "matchManagers": ["gomod"],
    "matchUpdateTypes": ["patch", "digest"],
    "semanticCommitType": "fix"
  },
  {
    "matchManagers": ["github-actions"],
    "semanticCommitType": "chore"
  }
]
Update Manager Commit émis Release
minor gomod feat(deps): version mineure
patch / digest gomod fix(deps): version patch
tout github-actions chore(deps): aucune

Pourquoi les actions ne déclenchent pas de release

Un bump d'action CI ne change pas le binaire livré — il n'a aucune raison de produire une nouvelle version. On le garde en chore : mergé automatiquement, mais invisible pour le versioning. Chaque bump de module Go, lui, est compilé dans l'artefact et justifie une release.

Le workflow : taguer puis publier, en un seul job

svu lit les commits depuis le dernier tag et calcule le prochain vX.Y.Z ; un appel API pose le tag, GoReleaser enchaîne dans le même job. Le garde-fou est dans la comparaison : svu next renvoie la version courante quand rien ne justifie de release, donc next == current signifie « ne rien faire » — aucune release parasite sur un simple docs: ou chore:.

.github/workflows/release.yml
on:
  push:
    branches: [main]
    tags: ['v*']   # échappatoire : un tag posé à la main publie aussi

permissions:
  contents: write

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
        with:
          fetch-depth: 0
          persist-credentials: false
      # setup-go d'abord : il sert à installer svu, puis à GoReleaser
      - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
        with:
          go-version-file: go.mod
          cache: false
      - id: tag
        if: github.ref_type == 'branch'
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          # renovate: datasource=github-releases depName=caarlos0/svu
          SVU_VERSION: v3.4.1
        run: |
          set -euo pipefail
          GOBIN="${RUNNER_TEMP}/bin" go install "github.com/caarlos0/svu/v3@${SVU_VERSION}"
          svu="${RUNNER_TEMP}/bin/svu"

          current=$("$svu" current)
          next=$("$svu" next --v0)
          [ "$next" = "$current" ] && exit 0   # rien de releasable

          gh api "repos/${GITHUB_REPOSITORY}/git/refs" \
            -f "ref=refs/tags/${next}" -f "sha=${GITHUB_SHA}" --silent
          echo "new_tag=${next}" >> "$GITHUB_OUTPUT"
      - if: github.ref_type == 'tag' || steps.tag.outputs.new_tag != ''
        run: git fetch --tags --force
      # setup-go + GoReleaser, gardés par la même condition — voir l'article GoReleaser

go install passe par le proxy de modules Go, donc la version de svu est vérifiée contre la base de checksums — et le pin en variable d'env est suivi par Renovate via un customManager regex. Créer le tag par l'API plutôt qu'avec git push permet de garder persist-credentials: false sur le checkout.

perf: ne déclenche pas de release

svu applique la spec Conventional Commits à la lettre : seuls fix (patch) et feat (mineur) sont normatifs. Un perf: seul ne sort donc aucune version, et sa config ne permet pas d'ajouter des mots-clés. Si un gain de perf doit être livré tout de suite, il se type fix:. Le flag --v0 évite au passage qu'un breaking change fasse sauter un projet en 0.x directement en v1.0.0.

Pourquoi pas une action toute faite ?

Le réflexe est d'utiliser mathieudutour/github-tag-action, la plus répandue pour ça. Elle est abandonnée : plus de release depuis mars 2024, plus de commit depuis juin 2024, et toujours using: node20 — donc une alerte de dépréciation à chaque release, sans version vers laquelle migrer. Épingler par SHA protège d'un tag repointé, pas d'un projet mort.

Les remplaçantes évidentes ne tiennent pas l'examen : anothrNick/github-tag-action ne fait pas de conventional commits (elle cherche des hashtags #major/#minor/#patch, et détourner ses tokens donne un match de sous-chaîne où « prefix » déclenche un patch) ; TriPSs/conventional-changelog-action fonctionne mais écrit un CHANGELOG.md par défaut, ce qui salit l'arbre que GoReleaser va lire ; release-please et consorts imposent un modèle de release PR, soit exactement le clic humain que l'automerge cherche à supprimer.

D'où le choix d'un CLI maintenu qui ne fait que le calcul, plus un appel API. Leçon générale : sur un chemin de release qui détient contents: write, la maintenance de la dépendance compte autant que ses fonctionnalités.

Pourquoi tout dans un seul job, plutôt qu'un workflow qui tague et un autre qui publie sur le tag ? Parce qu'un tag poussé avec le GITHUB_TOKEN par défaut ne re-déclenche pas de workflow — garde-fou anti-boucle de GitHub. Séparer les deux imposerait un PAT dédié juste pour ré-armer le second workflow. En enchaînant calcul-du-tag et publication dans le même run, on s'en passe. La double condition (ref_type == 'tag' ou nouveau tag calculé) préserve l'échappatoire manuelle : un v* poussé à la main court-circuite le calcul et publie directement.

Résultat : Renovate merge un minor de dépendance, la CI passe, une version mineure sort et est publiée — sans qu'une main touche à un tag.

Récap

Ce qui fait la différence entre une CI Go qui marche et une CI Go qu'on laisse tourner sans y penser :

  • Actions épinglées par SHA, versions gardées lisibles en commentaire
  • permissions: au moindre privilège, par workflow et par job
  • persist-credentials: false sur chaque checkout
  • zizmor pour que tout ça reste vrai dans le temps
  • go test -race et build sur les trois OS
  • golangci-lint épinglé, exclusions justifiées, max-same-issues: 0 pour ne rien masquer
  • govulncheck pour les CVE réellement atteignables
  • reviewdog pour un feedback actionnable en PR
  • Renovate en automerge (minor/patch/digest) et releases déclenchées par le type de commit

Et après le CI ?

Une fois le CI vert et un tag posé — à la main ou calculé par la CI (voir plus haut) — c'est GoReleaser qui prend le relais : binaires multi-arch, images OCI, signatures cosign, publication Homebrew.