From a0c2f68f3635e4a26cbbea2d0a3aa6ea8256e5d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 16:35:54 +0000 Subject: [PATCH 1/2] ci: answer the shellcheck and actionlint findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nine findings across the shell scripts and the `run:` blocks, reducing to three causes. None had ever been reported by anything the repository runs: shellcheck and actionlint are not installed here, and SonarQube's shell analysis arrives after the merge. SC1091, twice, was real. `. "$(dirname "$0")/../trains.sh"` is a path shellcheck cannot resolve statically, so it was skipping the sourced file entirely — the single source of truth for the release trains went unchecked in both scripts that read it. A `# shellcheck source=` directive fixes the analysis, not just the message. The other seven are false positives, each annotated where it fires rather than disabled repository-wide. SC2016 (five) reads the Markdown backticks in a printf format as command substitution; every one of these formats emits a step summary or a pull-request comment. SC2317 (two) calls the hook's two rule functions unreachable, because they are reached through the `"rule_${rule}"` dispatch a static reader cannot follow — the rule's own message says to ignore it when the call is indirect. A `.shellcheckrc` would have silenced all seven in one line and blinded both rules everywhere else, including where they are right. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017bxVrnCNsXW3RvwmLc9Kvy --- .claude/hooks/coding-rules.sh | 2 ++ .github/workflows/adr-check.yml | 1 + .github/workflows/dependabot-autofix.yml | 3 +++ tools/analyzer-count-check/check-analyzer-count.sh | 1 + tools/changelog/collect-prs.sh | 1 + tools/packaging/release-notes.sh | 1 + 6 files changed, 9 insertions(+) diff --git a/.claude/hooks/coding-rules.sh b/.claude/hooks/coding-rules.sh index 068b0b75..f1a9111b 100755 --- a/.claude/hooks/coding-rules.sh +++ b/.claude/hooks/coding-rules.sh @@ -61,6 +61,7 @@ display="${file##*/}" # (documentation routinely shows `var` in sample code), an anonymous type (C# # gives no other spelling), and an occurrence inside a string literal (the # analyzer suites embed C# fixtures that deliberately use `var`). +# shellcheck disable=SC2317 # reached through the `"rule_${rule}"` dispatch in the run section below rule_explicit_types() { awk -v name="$display" ' { @@ -93,6 +94,7 @@ rule_explicit_types() { ' "$file" } +# shellcheck disable=SC2317 # reached through the `"${rule}_hint"` dispatch in the run section below explicit_types_hint() { printf '%s' "Coding rule — explicit types (CLAUDE.md, \"Coding rules\"). This file now declares inferred types: diff --git a/.github/workflows/adr-check.yml b/.github/workflows/adr-check.yml index 2ec34616..2f7226fe 100644 --- a/.github/workflows/adr-check.yml +++ b/.github/workflows/adr-check.yml @@ -123,6 +123,7 @@ jobs: if [ "$needs" = "true" ] && [ -n "$report" ]; then printf '%s\n' "$report" >> "$GITHUB_STEP_SUMMARY" else + # shellcheck disable=SC2016 # the backticks are Markdown, not command substitution printf '### 🏛️ ADR check (advisory)\n\nNo architectural decision detected on `%s` versus `%s`. Nothing to flag.\n' \ "$BRANCH" "$BASE" >> "$GITHUB_STEP_SUMMARY" fi diff --git a/.github/workflows/dependabot-autofix.yml b/.github/workflows/dependabot-autofix.yml index 1cb35866..8adec3de 100644 --- a/.github/workflows/dependabot-autofix.yml +++ b/.github/workflows/dependabot-autofix.yml @@ -302,13 +302,16 @@ jobs: patch="$(jq -r '.patch // ""' verdict.json)" msg="$(jq -r '.commit_message // ""' verdict.json)" if [ -n "$patch" ]; then + # shellcheck disable=SC2016 # the backticks are Markdown, not command substitution printf 'Apply it yourself:\n\n```diff\n%s\n```\n\n' "$patch" + # shellcheck disable=SC2016 # the backticks are Markdown, not command substitution [ -n "$msg" ] && printf 'Commit it as:\n\n```\n%s\n```\n\n' "$msg" fi else printf '**🧑 Needs a human** — %s.\n\n' "$category" [ -n "$explanation" ] && printf '%s\n\n' "$explanation" fi + # shellcheck disable=SC2016 # the backticks are Markdown, not command substitution printf '_No dependency version was changed. See `%s`._\n' "$doc" } > comment-body.md diff --git a/tools/analyzer-count-check/check-analyzer-count.sh b/tools/analyzer-count-check/check-analyzer-count.sh index 01da5364..12978203 100755 --- a/tools/analyzer-count-check/check-analyzer-count.sh +++ b/tools/analyzer-count-check/check-analyzer-count.sh @@ -68,6 +68,7 @@ fi if [ "$status" -ne 0 ]; then printf '\nUpdate the bullet in FirstClassErrors/README.nuget.md to read:\n' >&2 + # shellcheck disable=SC2016 # the backticks are Markdown code spans in the format string, not command substitution printf ' **%s Roslyn analyzers in the box (`%s`-`%s`).**\n' "$count" "$low" "$high" >&2 exit 1 fi diff --git a/tools/changelog/collect-prs.sh b/tools/changelog/collect-prs.sh index 779f8543..cbf75cd0 100755 --- a/tools/changelog/collect-prs.sh +++ b/tools/changelog/collect-prs.sh @@ -28,6 +28,7 @@ set -eu component="${1:-}" # Tag prefix and train scopes come from tools/trains.sh (the single source of truth # shared with release-notes.sh), so the changelog and the release notes never diverge. +# shellcheck source=tools/trains.sh . "$(dirname "$0")/../trains.sh" prefix="$(prefix_of "$component")" train_scopes="$(scopes_of "$component")" diff --git a/tools/packaging/release-notes.sh b/tools/packaging/release-notes.sh index 4898848e..792c8b0c 100755 --- a/tools/packaging/release-notes.sh +++ b/tools/packaging/release-notes.sh @@ -30,6 +30,7 @@ end_ref="${3:-$current_tag}" # Tag prefix and train scopes come from tools/trains.sh (the single source of truth # shared with the changelog tooling), so the two can never disagree on the partition. +# shellcheck source=tools/trains.sh . "$(dirname "$0")/../trains.sh" prefix="$(prefix_of "$scope")" train_scopes="$(scopes_of "$scope")" From 9ccacfc0f83043d4dc7fb0d26124352123acb475 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 16:36:07 +0000 Subject: [PATCH 2/2] ci: lint the scripts and workflows on every pull request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every analysis in this repository runs inside a build — the Roslyn analyzers, the explicit-type rule ADR-0055 restated in .editorconfig, the warning ratchet — so a contributor meets it while writing the code. The shell scripts and the workflow files had no such moment. The only thing reading them was the sonar scan, which reports after the merge and enforces nothing: its job goes green as soon as the analysis uploads, whatever the Quality Gate says. Two findings typed VULNERABILITY reached main through that gap, and 21 shell findings accumulated behind it. The new `lint` workflow runs shellcheck over every *.sh and actionlint over .github/workflows on every pull request. Both run on our own runners, so the signal arrives before the merge and survives a SonarQube outage — which the current arrangement does not: the sonar check is required and calls the service, so an outage blocks merging while a red Quality Gate does not. shellcheck is preinstalled on the runner image, so no third-party action enters the supply chain. actionlint is a pinned release tarball verified by SHA-256, for the same reason Scorecard's Pinned-Dependencies check exists. The job declares its own `contents: read` rather than inheriting a workflow-level grant — the S8264 lesson applied to the workflow that now guards against it. The bar is zero findings, `info` included, and the tree is clean at that bar. A lower bar would let `info` accumulate exactly the way the Sonar report did. Documented in the workflow reference, English and French, including what this does NOT cover: actionlint audits correctness, not security posture, so the class that produced the two VULNERABILITY findings needs a dedicated auditor and a separate decision. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017bxVrnCNsXW3RvwmLc9Kvy --- .github/workflows/lint.yml | 86 +++++++++++++++++ .../for-maintainers/workflows/README.fr.md | 1 + .../for-maintainers/workflows/README.md | 1 + .../for-maintainers/workflows/lint.en.md | 89 ++++++++++++++++++ .../for-maintainers/workflows/lint.fr.md | 93 +++++++++++++++++++ 5 files changed, 270 insertions(+) create mode 100644 .github/workflows/lint.yml create mode 100644 doc/handwritten/for-maintainers/workflows/lint.en.md create mode 100644 doc/handwritten/for-maintainers/workflows/lint.fr.md diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 00000000..754c50f6 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,86 @@ +name: lint + +# Static analysis for the files the C# compiler never sees: the POSIX shell scripts under +# tools/ and .claude/hooks/, and the workflow definitions themselves. +# +# Why this workflow exists. Every other analysis in this repository runs inside a build — +# the Roslyn analyzers, the style rule ADR-0055 restated in .editorconfig, the warning +# ratchet in Directory.Build.props — so a contributor meets it at the moment they write the +# code. Shell and YAML had no such moment: the only thing reading them was the SonarQube +# Cloud scan, which reports AFTER the merge and enforces nothing (its workflow is green as +# soon as the upload succeeds, whatever the Quality Gate says). Two findings typed +# VULNERABILITY reached `main` that way. This closes the gap with tools that run on our own +# runners, so the signal does not depend on a third-party service being up. + +on: + push: + branches: + - main + pull_request: + branches: + - main + workflow_dispatch: + +# Cancel superseded runs on the same branch / PR. +concurrency: + group: lint-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + scripts-and-workflows: + name: Lint scripts and workflows + runs-on: ubuntu-latest + # Both tools are static and finish in seconds; the cap only guards a hung download. + timeout-minutes: 10 + # Declared per job rather than at workflow level, so a job added later inherits nothing + # it did not ask for (Sonar githubactions:S8264). This job only reads the checkout. + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: Lint the shell scripts + # shellcheck ships preinstalled on the ubuntu runner image, so there is nothing to + # fetch and no third-party action in the supply chain. The version is printed so a + # future failure caused by a runner-image upgrade is readable from the log alone. + # + # Every script here is `#!/bin/sh`; shellcheck reads the shebang and applies the + # POSIX dialect, which is what should be checked — `local`, arrays and `[[` are not + # available on the shells these run on (decision on the POSIX rules: ADR-0060). + # + # The bar is ZERO findings at every severity, `info` included. The tree is clean at + # that bar today: the two SC1091 findings were fixed with `source=` directives, and + # the SC2016/SC2317 false positives carry an inline `disable` naming its reason. A + # lower bar would let `info` accumulate exactly the way the Sonar report did. + run: | + shellcheck --version | sed -n 's/^version: /shellcheck /p' + find . -name '*.sh' -not -path './.git/*' -not -path '*/bin/*' -not -path '*/obj/*' \ + -print0 | xargs -0 --no-run-if-empty shellcheck + + - name: Lint the workflow definitions + # actionlint checks what YAML alone cannot: `${{ }}` expression types, action inputs + # against each action's own schema, `needs`/matrix references, cron syntax, and the + # shell of every `run:` block (it embeds shellcheck, which is why the run blocks + # carry the same inline `disable` directives the scripts do). + # + # Pinned to a release tarball and verified by checksum rather than run through a + # third-party action: an unpinned action is exactly what OpenSSF Scorecard's + # Pinned-Dependencies check counts against this repository. Bump both lines together. + # + # NOT covered by this step: actionlint audits correctness, not security posture. It + # does not flag over-broad permissions, spoofable actor checks or dangerous triggers + # — the class that produced this repository's two VULNERABILITY findings. A dedicated + # auditor (zizmor) is the tool for that and is a separate decision. + env: + ACTIONLINT_VERSION: '1.7.7' + ACTIONLINT_SHA256: '023070a287cd8cccd71515fedc843f1985bf96c436b7effaecce67290e7e0757' + run: | + set -eu + archive="actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" + curl -sSfL -o "$archive" \ + "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/${archive}" + echo "${ACTIONLINT_SHA256} ${archive}" | sha256sum --check --strict + tar xzf "$archive" actionlint + ./actionlint --version + ./actionlint -color diff --git a/doc/handwritten/for-maintainers/workflows/README.fr.md b/doc/handwritten/for-maintainers/workflows/README.fr.md index 76ee8a7f..c09ac935 100644 --- a/doc/handwritten/for-maintainers/workflows/README.fr.md +++ b/doc/handwritten/for-maintainers/workflows/README.fr.md @@ -76,6 +76,7 @@ documentées une seule fois ici plutôt que répétées sur chaque page. | [`justdummies-mutation`](justdummies-mutation.fr.md) | Idem pour les packages JustDummies, avec son propre check obligatoire — séparé pour que la future séparation de dépôt soit un déplacement de fichier. | | [`analyzers`](analyzers.fr.md) | Dogfood des analyzers Roslyn embarqués, y compris sur le plus vieux compilateur supporté (le floor Roslyn). | | [`commit-lint`](commit-lint.fr.md) | Impose la convention Conventional Commits sur chaque commit de PR, via le même script que le hook local. | +| [`lint`](lint.fr.md) | shellcheck et actionlint sur les fichiers que le compilateur C# ne voit jamais — les scripts POSIX et les définitions de workflow. Zéro constat, `info` compris. | | [`adr-check`](adr-check.fr.md) | Consultatif, dispatch manuel : confronte une branche à la base d'ADR (nouvelle décision / remplacement / conflit). Le repli pour les contributeurs sans Claude Code ; ne bloque jamais. | ### Sécurité & chaîne d'approvisionnement diff --git a/doc/handwritten/for-maintainers/workflows/README.md b/doc/handwritten/for-maintainers/workflows/README.md index 67b93eeb..81bc202c 100644 --- a/doc/handwritten/for-maintainers/workflows/README.md +++ b/doc/handwritten/for-maintainers/workflows/README.md @@ -72,6 +72,7 @@ here instead of being repeated on every page. | [`justdummies-mutation`](justdummies-mutation.en.md) | The same, for the JustDummies packages, with its own required check — kept separate so the future repository split is a file move. | | [`analyzers`](analyzers.en.md) | Dogfood the bundled Roslyn analyzers, including on the oldest supported compiler (the Roslyn floor). | | [`commit-lint`](commit-lint.en.md) | Enforce the Conventional Commits convention on every PR commit, using the same script as the local hook. | +| [`lint`](lint.en.md) | shellcheck and actionlint over the files the C# compiler never sees — the POSIX scripts and the workflow definitions. Zero findings, `info` included. | | [`adr-check`](adr-check.en.md) | Advisory, manual dispatch: check a branch against the ADR base (new decision / supersede / conflict). The fallback for contributors without Claude Code; never blocks. | ### Security & supply chain diff --git a/doc/handwritten/for-maintainers/workflows/lint.en.md b/doc/handwritten/for-maintainers/workflows/lint.en.md new file mode 100644 index 00000000..881b7a6a --- /dev/null +++ b/doc/handwritten/for-maintainers/workflows/lint.en.md @@ -0,0 +1,89 @@ +# `lint` workflow + +🌍 🇬🇧 English (this file) · 🇫🇷 [Français](lint.fr.md) + +> Maintainer documentation — part of the [workflow reference](README.md). +> Not part of the user documentation under `doc/`. + +**Workflow file:** [`.github/workflows/lint.yml`](../../../../.github/workflows/lint.yml) + +## What it is for + +It statically analyses the files the C# compiler never sees: the POSIX shell +scripts under `tools/` and `.claude/hooks/`, and the workflow definitions in +`.github/workflows/` themselves. + +Every other analysis in this repository runs **inside a build** — the Roslyn +analyzers, the explicit-type rule ADR-0055 restated in `.editorconfig`, the +warning ratchet in `Directory.Build.props` — so a contributor meets it at the +moment they write the code. Shell and YAML had no such moment. The only thing +reading them was the [`sonar`](sonar.en.md) scan, which reports **after** the +merge and enforces nothing: its job is green as soon as the analysis uploads, +whatever the Quality Gate says. Two findings typed VULNERABILITY reached `main` +that way, and 21 shell findings accumulated unseen. + +This workflow closes that gap with tools that run on our own runners, so the +signal arrives before the merge and does not depend on a third-party service +being reachable. + +## When it runs + +- On every **push to `main`**. +- On every **pull request targeting `main`**. +- On demand via **`workflow_dispatch`**. + +## How it runs + +One job, `Lint scripts and workflows`, on Linux: + +1. **shellcheck** over every `*.sh` in the repository. It ships preinstalled on + the runner image, so there is nothing to fetch and no third-party action in + the supply chain. +2. **actionlint** over `.github/workflows/`. It checks what YAML alone cannot: + `${{ }}` expression types, action inputs against each action's own schema, + `needs` and matrix references, cron syntax, and — through an embedded + shellcheck — the shell of every `run:` block. + +## Permissions & security + +`contents: read`, declared **on the job** rather than at workflow level, so a job +added later inherits nothing it did not ask for (this is Sonar +`githubactions:S8264`, and the reason the two mutation workflows were changed the +same way). + +actionlint is fetched as a **pinned release tarball verified by SHA-256**, not +run through a third-party action: an unpinned action is what OpenSSF Scorecard's +Pinned-Dependencies check counts against this repository. The version and the +checksum sit next to each other in the workflow and are bumped together. + +## Handle with care + +- **The bar is zero findings, `info` included.** The tree is clean at that bar, + so anything new is genuinely new. A lower bar would let `info` accumulate + exactly the way the Sonar report did — which is the problem this workflow + exists to prevent, not to reproduce. +- **False positives are annotated in place, never disabled globally.** Three + patterns are silenced with an inline `# shellcheck disable=` naming its reason: + `SC2016` where a `printf` format carries Markdown backticks (read as command + substitution), and `SC2317` on the two hook functions reached through the + `"rule_${rule}"` dispatch shellcheck cannot follow. A repository-wide + `.shellcheckrc` would blind the rules everywhere, including where they are + right. +- **The scripts are `#!/bin/sh`, and shellcheck applies the POSIX dialect.** + That is deliberate: `local`, arrays and `[[` are not available on the shells + these run on, and the POSIX rules the scripts are held to are a recorded + decision (ADR-0060). +- **actionlint audits correctness, not security posture.** It does not flag + over-broad permissions, spoofable actor checks or dangerous triggers — the very + class that produced this repository's two VULNERABILITY findings. A dedicated + auditor (`zizmor`) covers that and is a separate decision, not something this + workflow quietly provides. +- **This check only helps if it is required.** As with the other quality checks, + it blocks a merge only when branch protection on `main` marks it **required**. + +## Related + +- [`sonar`](sonar.en.md) — the analysis this workflow brings forward. It stays + the reporting and coverage view; it is not, and was never, an enforcement gate. +- [`ci`](ci.en.md) — where the warning ratchet enforces the equivalent bar on the + C# side. diff --git a/doc/handwritten/for-maintainers/workflows/lint.fr.md b/doc/handwritten/for-maintainers/workflows/lint.fr.md new file mode 100644 index 00000000..db6a2309 --- /dev/null +++ b/doc/handwritten/for-maintainers/workflows/lint.fr.md @@ -0,0 +1,93 @@ +# Workflow `lint` + +🌍 🇫🇷 Français (ce fichier) · 🇬🇧 [English](lint.en.md) + +> Documentation mainteneur — fait partie de la [référence des workflows](README.fr.md). +> Ne fait pas partie de la documentation utilisateur sous `doc/`. + +**Fichier du workflow :** [`.github/workflows/lint.yml`](../../../../.github/workflows/lint.yml) + +## À quoi il sert + +Il analyse statiquement les fichiers que le compilateur C# ne voit jamais : les +scripts shell POSIX sous `tools/` et `.claude/hooks/`, et les définitions de +workflow de `.github/workflows/` elles-mêmes. + +Toute autre analyse de ce dépôt tourne **dans une compilation** — les analyseurs +Roslyn, la règle du type explicite redite dans `.editorconfig` par l'ADR-0055, le +ratchet de warnings de `Directory.Build.props` — si bien qu'un contributeur la +rencontre au moment où il écrit le code. Le shell et le YAML n'avaient pas ce +moment. La seule chose qui les lisait était l'analyse [`sonar`](sonar.fr.md), qui +rapporte **après** le merge et n'applique rien : son job est vert dès que +l'analyse est téléversée, quoi que dise le Quality Gate. Deux constats typés +VULNERABILITY ont atteint `main` par ce chemin, et 21 constats shell s'y sont +accumulés sans être vus. + +Ce workflow referme ce trou avec des outils qui tournent sur nos propres +*runners* : le signal arrive avant le merge et ne dépend pas de la disponibilité +d'un service tiers. + +## Quand il tourne + +- À chaque **push sur `main`**. +- À chaque **pull request visant `main`**. +- À la demande via **`workflow_dispatch`**. + +## Comment il tourne + +Un job, `Lint scripts and workflows`, sous Linux : + +1. **shellcheck** sur chaque `*.sh` du dépôt. Il est préinstallé sur l'image du + *runner* : rien à télécharger, aucune action tierce dans la chaîne + d'approvisionnement. +2. **actionlint** sur `.github/workflows/`. Il vérifie ce que le YAML seul ne + peut pas : le typage des expressions `${{ }}`, les entrées d'actions face au + schéma de chaque action, les références `needs` et matrice, la syntaxe cron + et — via un shellcheck embarqué — le shell de chaque bloc `run:`. + +## Permissions & sécurité + +`contents: read`, déclaré **sur le job** plutôt qu'au niveau du workflow, pour +qu'un job ajouté plus tard n'hérite de rien qu'il n'ait demandé (c'est la règle +Sonar `githubactions:S8264`, et la raison pour laquelle les deux workflows de +mutation ont été modifiés de la même façon). + +actionlint est récupéré comme **archive de version épinglée et vérifiée par +SHA-256**, et non exécuté via une action tierce : une action non épinglée est +précisément ce que le contrôle Pinned-Dependencies d'OpenSSF Scorecard retient +contre ce dépôt. La version et l'empreinte se suivent dans le workflow et se +mettent à jour ensemble. + +## À manier avec précaution + +- **La barre est à zéro constat, `info` compris.** L'arbre est propre à cette + barre : tout nouveau constat est donc réellement nouveau. Une barre plus basse + laisserait les `info` s'accumuler exactement comme dans le rapport Sonar — ce + que ce workflow existe pour empêcher, non pour reproduire. +- **Les faux positifs sont annotés sur place, jamais désactivés globalement.** + Trois motifs sont tus par un `# shellcheck disable=` en ligne portant sa + raison : `SC2016` là où un format `printf` contient des *backticks* Markdown + (lus comme une substitution de commande), et `SC2317` sur les deux fonctions de + *hook* atteintes par la répartition `"rule_${rule}"` que shellcheck ne sait pas + suivre. Un `.shellcheckrc` à l'échelle du dépôt aveuglerait ces règles partout, + y compris là où elles ont raison. +- **Les scripts sont en `#!/bin/sh`, et shellcheck applique le dialecte POSIX.** + C'est délibéré : `local`, les tableaux et `[[` ne sont pas disponibles sur les + shells qui les exécutent, et les règles POSIX auxquelles ces scripts sont tenus + sont une décision consignée (ADR-0060). +- **actionlint audite la correction, pas la posture de sécurité.** Il ne signale + ni permissions trop larges, ni vérification d'acteur usurpable, ni déclencheur + dangereux — la classe même qui a produit les deux constats VULNERABILITY de ce + dépôt. Un auditeur dédié (`zizmor`) couvre cela et relève d'une décision à + part, que ce workflow ne fournit pas en douce. +- **Ce contrôle ne sert que s'il est requis.** Comme les autres contrôles de + qualité, il ne bloque un merge que si la protection de branche de `main` le + marque **required**. + +## Voir aussi + +- [`sonar`](sonar.fr.md) — l'analyse que ce workflow ramène en amont. Elle reste + la vue de rapport et de couverture ; elle n'est pas, et n'a jamais été, un + garde-fou. +- [`ci`](ci.fr.md) — là où le ratchet de warnings applique la barre équivalente + côté C#.