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/.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#. 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")"