Skip to content

Commit 317a161

Browse files
committed
ci: lint the scripts and workflows on every pull request
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017bxVrnCNsXW3RvwmLc9Kvy
1 parent 0375968 commit 317a161

5 files changed

Lines changed: 270 additions & 0 deletions

File tree

.github/workflows/lint.yml

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
name: lint
2+
3+
# Static analysis for the files the C# compiler never sees: the POSIX shell scripts under
4+
# tools/ and .claude/hooks/, and the workflow definitions themselves.
5+
#
6+
# Why this workflow exists. Every other analysis in this repository runs inside a build —
7+
# the Roslyn analyzers, the style rule ADR-0055 restated in .editorconfig, the warning
8+
# ratchet in Directory.Build.props — so a contributor meets it at the moment they write the
9+
# code. Shell and YAML had no such moment: the only thing reading them was the SonarQube
10+
# Cloud scan, which reports AFTER the merge and enforces nothing (its workflow is green as
11+
# soon as the upload succeeds, whatever the Quality Gate says). Two findings typed
12+
# VULNERABILITY reached `main` that way. This closes the gap with tools that run on our own
13+
# runners, so the signal does not depend on a third-party service being up.
14+
15+
on:
16+
push:
17+
branches:
18+
- main
19+
pull_request:
20+
branches:
21+
- main
22+
workflow_dispatch:
23+
24+
# Cancel superseded runs on the same branch / PR.
25+
concurrency:
26+
group: lint-${{ github.workflow }}-${{ github.ref }}
27+
cancel-in-progress: true
28+
29+
jobs:
30+
scripts-and-workflows:
31+
name: Lint scripts and workflows
32+
runs-on: ubuntu-latest
33+
# Both tools are static and finish in seconds; the cap only guards a hung download.
34+
timeout-minutes: 10
35+
# Declared per job rather than at workflow level, so a job added later inherits nothing
36+
# it did not ask for (Sonar githubactions:S8264). This job only reads the checkout.
37+
permissions:
38+
contents: read
39+
steps:
40+
- name: Checkout
41+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
42+
43+
- name: Lint the shell scripts
44+
# shellcheck ships preinstalled on the ubuntu runner image, so there is nothing to
45+
# fetch and no third-party action in the supply chain. The version is printed so a
46+
# future failure caused by a runner-image upgrade is readable from the log alone.
47+
#
48+
# Every script here is `#!/bin/sh`; shellcheck reads the shebang and applies the
49+
# POSIX dialect, which is what should be checked — `local`, arrays and `[[` are not
50+
# available on the shells these run on (decision on the POSIX rules: ADR-0060).
51+
#
52+
# The bar is ZERO findings at every severity, `info` included. The tree is clean at
53+
# that bar today: the two SC1091 findings were fixed with `source=` directives, and
54+
# the SC2016/SC2317 false positives carry an inline `disable` naming its reason. A
55+
# lower bar would let `info` accumulate exactly the way the Sonar report did.
56+
run: |
57+
shellcheck --version | sed -n 's/^version: /shellcheck /p'
58+
find . -name '*.sh' -not -path './.git/*' -not -path '*/bin/*' -not -path '*/obj/*' \
59+
-print0 | xargs -0 --no-run-if-empty shellcheck
60+
61+
- name: Lint the workflow definitions
62+
# actionlint checks what YAML alone cannot: `${{ }}` expression types, action inputs
63+
# against each action's own schema, `needs`/matrix references, cron syntax, and the
64+
# shell of every `run:` block (it embeds shellcheck, which is why the run blocks
65+
# carry the same inline `disable` directives the scripts do).
66+
#
67+
# Pinned to a release tarball and verified by checksum rather than run through a
68+
# third-party action: an unpinned action is exactly what OpenSSF Scorecard's
69+
# Pinned-Dependencies check counts against this repository. Bump both lines together.
70+
#
71+
# NOT covered by this step: actionlint audits correctness, not security posture. It
72+
# does not flag over-broad permissions, spoofable actor checks or dangerous triggers
73+
# — the class that produced this repository's two VULNERABILITY findings. A dedicated
74+
# auditor (zizmor) is the tool for that and is a separate decision.
75+
env:
76+
ACTIONLINT_VERSION: '1.7.7'
77+
ACTIONLINT_SHA256: '023070a287cd8cccd71515fedc843f1985bf96c436b7effaecce67290e7e0757'
78+
run: |
79+
set -eu
80+
archive="actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz"
81+
curl -sSfL -o "$archive" \
82+
"https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/${archive}"
83+
echo "${ACTIONLINT_SHA256} ${archive}" | sha256sum --check --strict
84+
tar xzf "$archive" actionlint
85+
./actionlint --version
86+
./actionlint -color

doc/handwritten/for-maintainers/workflows/README.fr.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ documentées une seule fois ici plutôt que répétées sur chaque page.
7676
| [`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. |
7777
| [`analyzers`](analyzers.fr.md) | Dogfood des analyzers Roslyn embarqués, y compris sur le plus vieux compilateur supporté (le floor Roslyn). |
7878
| [`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. |
79+
| [`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. |
7980
| [`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. |
8081

8182
### Sécurité & chaîne d'approvisionnement

doc/handwritten/for-maintainers/workflows/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ here instead of being repeated on every page.
7272
| [`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. |
7373
| [`analyzers`](analyzers.en.md) | Dogfood the bundled Roslyn analyzers, including on the oldest supported compiler (the Roslyn floor). |
7474
| [`commit-lint`](commit-lint.en.md) | Enforce the Conventional Commits convention on every PR commit, using the same script as the local hook. |
75+
| [`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. |
7576
| [`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. |
7677

7778
### Security & supply chain
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
# `lint` workflow
2+
3+
🌍 🇬🇧 English (this file) · 🇫🇷 [Français](lint.fr.md)
4+
5+
> Maintainer documentation — part of the [workflow reference](README.md).
6+
> Not part of the user documentation under `doc/`.
7+
8+
**Workflow file:** [`.github/workflows/lint.yml`](../../../../.github/workflows/lint.yml)
9+
10+
## What it is for
11+
12+
It statically analyses the files the C# compiler never sees: the POSIX shell
13+
scripts under `tools/` and `.claude/hooks/`, and the workflow definitions in
14+
`.github/workflows/` themselves.
15+
16+
Every other analysis in this repository runs **inside a build** — the Roslyn
17+
analyzers, the explicit-type rule ADR-0055 restated in `.editorconfig`, the
18+
warning ratchet in `Directory.Build.props` — so a contributor meets it at the
19+
moment they write the code. Shell and YAML had no such moment. The only thing
20+
reading them was the [`sonar`](sonar.en.md) scan, which reports **after** the
21+
merge and enforces nothing: its job is green as soon as the analysis uploads,
22+
whatever the Quality Gate says. Two findings typed VULNERABILITY reached `main`
23+
that way, and 21 shell findings accumulated unseen.
24+
25+
This workflow closes that gap with tools that run on our own runners, so the
26+
signal arrives before the merge and does not depend on a third-party service
27+
being reachable.
28+
29+
## When it runs
30+
31+
- On every **push to `main`**.
32+
- On every **pull request targeting `main`**.
33+
- On demand via **`workflow_dispatch`**.
34+
35+
## How it runs
36+
37+
One job, `Lint scripts and workflows`, on Linux:
38+
39+
1. **shellcheck** over every `*.sh` in the repository. It ships preinstalled on
40+
the runner image, so there is nothing to fetch and no third-party action in
41+
the supply chain.
42+
2. **actionlint** over `.github/workflows/`. It checks what YAML alone cannot:
43+
`${{ }}` expression types, action inputs against each action's own schema,
44+
`needs` and matrix references, cron syntax, and — through an embedded
45+
shellcheck — the shell of every `run:` block.
46+
47+
## Permissions & security
48+
49+
`contents: read`, declared **on the job** rather than at workflow level, so a job
50+
added later inherits nothing it did not ask for (this is Sonar
51+
`githubactions:S8264`, and the reason the two mutation workflows were changed the
52+
same way).
53+
54+
actionlint is fetched as a **pinned release tarball verified by SHA-256**, not
55+
run through a third-party action: an unpinned action is what OpenSSF Scorecard's
56+
Pinned-Dependencies check counts against this repository. The version and the
57+
checksum sit next to each other in the workflow and are bumped together.
58+
59+
## Handle with care
60+
61+
- **The bar is zero findings, `info` included.** The tree is clean at that bar,
62+
so anything new is genuinely new. A lower bar would let `info` accumulate
63+
exactly the way the Sonar report did — which is the problem this workflow
64+
exists to prevent, not to reproduce.
65+
- **False positives are annotated in place, never disabled globally.** Three
66+
patterns are silenced with an inline `# shellcheck disable=` naming its reason:
67+
`SC2016` where a `printf` format carries Markdown backticks (read as command
68+
substitution), and `SC2317` on the two hook functions reached through the
69+
`"rule_${rule}"` dispatch shellcheck cannot follow. A repository-wide
70+
`.shellcheckrc` would blind the rules everywhere, including where they are
71+
right.
72+
- **The scripts are `#!/bin/sh`, and shellcheck applies the POSIX dialect.**
73+
That is deliberate: `local`, arrays and `[[` are not available on the shells
74+
these run on, and the POSIX rules the scripts are held to are a recorded
75+
decision (ADR-0060).
76+
- **actionlint audits correctness, not security posture.** It does not flag
77+
over-broad permissions, spoofable actor checks or dangerous triggers — the very
78+
class that produced this repository's two VULNERABILITY findings. A dedicated
79+
auditor (`zizmor`) covers that and is a separate decision, not something this
80+
workflow quietly provides.
81+
- **This check only helps if it is required.** As with the other quality checks,
82+
it blocks a merge only when branch protection on `main` marks it **required**.
83+
84+
## Related
85+
86+
- [`sonar`](sonar.en.md) — the analysis this workflow brings forward. It stays
87+
the reporting and coverage view; it is not, and was never, an enforcement gate.
88+
- [`ci`](ci.en.md) — where the warning ratchet enforces the equivalent bar on the
89+
C# side.
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
# Workflow `lint`
2+
3+
🌍 🇫🇷 Français (ce fichier) · 🇬🇧 [English](lint.en.md)
4+
5+
> Documentation mainteneur — fait partie de la [référence des workflows](README.fr.md).
6+
> Ne fait pas partie de la documentation utilisateur sous `doc/`.
7+
8+
**Fichier du workflow :** [`.github/workflows/lint.yml`](../../../../.github/workflows/lint.yml)
9+
10+
## À quoi il sert
11+
12+
Il analyse statiquement les fichiers que le compilateur C# ne voit jamais : les
13+
scripts shell POSIX sous `tools/` et `.claude/hooks/`, et les définitions de
14+
workflow de `.github/workflows/` elles-mêmes.
15+
16+
Toute autre analyse de ce dépôt tourne **dans une compilation** — les analyseurs
17+
Roslyn, la règle du type explicite redite dans `.editorconfig` par l'ADR-0055, le
18+
ratchet de warnings de `Directory.Build.props` — si bien qu'un contributeur la
19+
rencontre au moment où il écrit le code. Le shell et le YAML n'avaient pas ce
20+
moment. La seule chose qui les lisait était l'analyse [`sonar`](sonar.fr.md), qui
21+
rapporte **après** le merge et n'applique rien : son job est vert dès que
22+
l'analyse est téléversée, quoi que dise le Quality Gate. Deux constats typés
23+
VULNERABILITY ont atteint `main` par ce chemin, et 21 constats shell s'y sont
24+
accumulés sans être vus.
25+
26+
Ce workflow referme ce trou avec des outils qui tournent sur nos propres
27+
*runners* : le signal arrive avant le merge et ne dépend pas de la disponibilité
28+
d'un service tiers.
29+
30+
## Quand il tourne
31+
32+
- À chaque **push sur `main`**.
33+
- À chaque **pull request visant `main`**.
34+
- À la demande via **`workflow_dispatch`**.
35+
36+
## Comment il tourne
37+
38+
Un job, `Lint scripts and workflows`, sous Linux :
39+
40+
1. **shellcheck** sur chaque `*.sh` du dépôt. Il est préinstallé sur l'image du
41+
*runner* : rien à télécharger, aucune action tierce dans la chaîne
42+
d'approvisionnement.
43+
2. **actionlint** sur `.github/workflows/`. Il vérifie ce que le YAML seul ne
44+
peut pas : le typage des expressions `${{ }}`, les entrées d'actions face au
45+
schéma de chaque action, les références `needs` et matrice, la syntaxe cron
46+
et — via un shellcheck embarqué — le shell de chaque bloc `run:`.
47+
48+
## Permissions & sécurité
49+
50+
`contents: read`, déclaré **sur le job** plutôt qu'au niveau du workflow, pour
51+
qu'un job ajouté plus tard n'hérite de rien qu'il n'ait demandé (c'est la règle
52+
Sonar `githubactions:S8264`, et la raison pour laquelle les deux workflows de
53+
mutation ont été modifiés de la même façon).
54+
55+
actionlint est récupéré comme **archive de version épinglée et vérifiée par
56+
SHA-256**, et non exécuté via une action tierce : une action non épinglée est
57+
précisément ce que le contrôle Pinned-Dependencies d'OpenSSF Scorecard retient
58+
contre ce dépôt. La version et l'empreinte se suivent dans le workflow et se
59+
mettent à jour ensemble.
60+
61+
## À manier avec précaution
62+
63+
- **La barre est à zéro constat, `info` compris.** L'arbre est propre à cette
64+
barre : tout nouveau constat est donc réellement nouveau. Une barre plus basse
65+
laisserait les `info` s'accumuler exactement comme dans le rapport Sonar — ce
66+
que ce workflow existe pour empêcher, non pour reproduire.
67+
- **Les faux positifs sont annotés sur place, jamais désactivés globalement.**
68+
Trois motifs sont tus par un `# shellcheck disable=` en ligne portant sa
69+
raison : `SC2016` là où un format `printf` contient des *backticks* Markdown
70+
(lus comme une substitution de commande), et `SC2317` sur les deux fonctions de
71+
*hook* atteintes par la répartition `"rule_${rule}"` que shellcheck ne sait pas
72+
suivre. Un `.shellcheckrc` à l'échelle du dépôt aveuglerait ces règles partout,
73+
y compris là où elles ont raison.
74+
- **Les scripts sont en `#!/bin/sh`, et shellcheck applique le dialecte POSIX.**
75+
C'est délibéré : `local`, les tableaux et `[[` ne sont pas disponibles sur les
76+
shells qui les exécutent, et les règles POSIX auxquelles ces scripts sont tenus
77+
sont une décision consignée (ADR-0060).
78+
- **actionlint audite la correction, pas la posture de sécurité.** Il ne signale
79+
ni permissions trop larges, ni vérification d'acteur usurpable, ni déclencheur
80+
dangereux — la classe même qui a produit les deux constats VULNERABILITY de ce
81+
dépôt. Un auditeur dédié (`zizmor`) couvre cela et relève d'une décision à
82+
part, que ce workflow ne fournit pas en douce.
83+
- **Ce contrôle ne sert que s'il est requis.** Comme les autres contrôles de
84+
qualité, il ne bloque un merge que si la protection de branche de `main` le
85+
marque **required**.
86+
87+
## Voir aussi
88+
89+
- [`sonar`](sonar.fr.md) — l'analyse que ce workflow ramène en amont. Elle reste
90+
la vue de rapport et de couverture ; elle n'est pas, et n'a jamais été, un
91+
garde-fou.
92+
- [`ci`](ci.fr.md) — là où le ratchet de warnings applique la barre équivalente
93+
côté C#.

0 commit comments

Comments
 (0)