Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,12 @@ jobs:
run: |
set -eu
archive="actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz"
curl -sSfL -o "$archive" \
# --proto '=https' --proto-redir '=https' refuse anything but HTTPS, on the initial request
# AND on every redirect. Without them -L would follow a redirect to plaintext http, which is
# what Sonar's githubactions:S6506 flags, correctly: a release URL is attacker-influenced the
# moment DNS or the CDN is. The checksum below would still catch tampering, but only after the
# bytes had crossed the network in clear.
curl --proto '=https' --proto-redir '=https' -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
Expand Down
79 changes: 79 additions & 0 deletions .github/workflows/sonar-gate.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
name: sonar-gate

# Reads the SonarCloud Quality Gate verdict every night and fails when it is not green.
#
# The gap this closes. `dotnet-sonarscanner end` uploads the analysis and returns; it neither waits
# for the gate nor reads it, and no GitHub check carries the verdict — of the 28 checks on a recent
# pull request, the only Sonar one was the repository's own analysis job. The gate was therefore
# computed by SonarCloud and enforced by nothing. Two findings typed VULNERABILITY reached `main`
# behind a permanently green `sonar` workflow.
#
# Why not simply make the scanner wait (`sonar.qualitygate.wait`). Because `sonar` is a REQUIRED
# check that calls the service, so a SonarCloud outage already blocks every merge while a red gate
# does not; adding the wait would deepen that coupling rather than remove it. Reading the verdict
# from a SCHEDULED job separates the two questions: the verdict gets enforced, and an outage costs
# a red nightly instead of a frozen repository. That combination — not blocking a merge, and
# reading the gate — is the one ADR-0062 left open, and this is it.
#
# Why this is not redundant with the build. build/sonar-profile.globalconfig enforces the C# rules
# the SonarAnalyzer NuGet package implements, which is a strict SUBSET of what the gate measures:
# - symbolic-execution rules the package does not run. Measured: an S2583 violation sat in this
# repository with the rule enforced at `warning`, and the local build reported nothing;
# - every non-C# family — githubactions, shell, secrets, xml, json, yaml;
# - coverage, duplication and hotspot review, which no analyzer can answer.
# Both of the findings that last turned this gate red were in those classes. The build hardening
# and this check are complements.
#
# Nightly, unlike the weekly `sonar-profile` check: a quality profile moves on a vendor's release
# cadence, but the gate moves with every merge.

on:
schedule:
# 04:11 UTC daily — in the gap left by the weekly jobs, which run Monday between 03:00 and 06:30.
- cron: '11 4 * * *'
workflow_dispatch:

# Cancel superseded runs (a dispatch on top of a scheduled one).
concurrency:
group: sonar-gate-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
gate:
name: Quality gate verdict
runs-on: ubuntu-latest
# One API call; the cap only guards a hung request.
timeout-minutes: 10
# Declared per job so a job added later inherits nothing it did not ask for (Sonar
# githubactions:S8264). This job reads the checkout and calls a public API — nothing else.
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7

- name: Read the quality gate
# The project is public, so this needs no secret. SONAR_TOKEN is passed from the same secret
# sonar.yml uses so the check keeps working the day the project stops being public; a missing
# secret is an empty string, which the script treats as unauthenticated.
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
run: tools/sonar-profile/check-gate.sh

- name: Say what to do about it
# The failing conditions are already in the log above; this is the pointer, not a repeat.
if: failure()
run: |
# shellcheck disable=SC2016 # the backticks are Markdown for the step summary, not command substitution
{
echo '### SonarCloud quality gate is red'
echo
echo 'The failing conditions are in the previous step.'
echo
echo 'A rating worse than A means the new-code period carries at least one issue of that'
echo 'kind — reliability is a Bug, security is a Vulnerability, maintainability a Code Smell.'
echo 'Coverage and duplication conditions read directly.'
echo
echo 'This check does NOT block any merge, by design. It is the standing alarm that the'
echo 'gate is not green, and it will stay red until someone acts on it.'
} >> "$GITHUB_STEP_SUMMARY"
9 changes: 6 additions & 3 deletions JustDummies.Analyzers/AnyChainFacts.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,12 @@ public static bool TryGetChain(IInvocationOperation invocation, KnownSymbols sym
outermost = parent;
}

for (IInvocationOperation? current = outermost; current is not null;) {
// Unbounded on purpose, and it terminates: every branch below either returns or steps `current` one
// link down the receiver chain, which a syntax tree makes finite. Written `;;` rather than with a
// `current is not null` guard because that guard could never be false — `next` is pattern-matched
// non-null — which left the exit path unreachable (Sonar csharpsquid:S2583). Same shape as the
// redraw loop in AnyPattern.
for (IInvocationOperation current = outermost;;) {
if (current.Instance is null) {
// A static call roots the chain: it is the factory when it belongs to Any, otherwise this is not a
// JustDummies chain at all.
Expand Down Expand Up @@ -64,8 +69,6 @@ public static bool TryGetChain(IInvocationOperation invocation, KnownSymbols sym
collected.Add(current);
current = next;
}

return false;
}

private static bool IsFactoryOwner(INamedTypeSymbol? type, KnownSymbols symbols) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@

🌍 🇫🇷 Français (ce fichier) · 🇬🇧 [English](0062-derive-the-build-rule-set-from-the-quality-profile.md)

**Statut :** Proposé
**Statut :** Accepté
**Proposé :** 2026-07-29
**Accepté :** 2026-07-30
**Décideurs :** Reefact

## Contexte
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@

🌍 🇬🇧 English (this file) · 🇫🇷 [Français](0062-derive-the-build-rule-set-from-the-quality-profile.fr.md)

**Status:** Proposed
**Status:** Accepted
**Proposed:** 2026-07-29
**Accepted:** 2026-07-30
**Decision Makers:** Reefact

## Context
Expand Down
2 changes: 1 addition & 1 deletion doc/handwritten/for-maintainers/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,4 +263,4 @@ Optional supporting material:
| [ADR-0059](0059-guard-the-recipe-versus-value-boundary-with-analyzers.md) | Guard the recipe-versus-value boundary with analyzers where the type system cannot reach it | Proposed |
| [ADR-0060](0060-let-stated-intent-outrank-generic-analyzer-advice.md) | Let stated intent outrank generic analyzer advice, and record the refusal beside the rule | Proposed |
| [ADR-0061](0061-run-the-justdummies-analyzers-on-the-repository-s-own-code.md) | Run the JustDummies analyzers on the repository's own code, so the rules are verified against code not written to please them | Accepted |
| [ADR-0062](0062-derive-the-build-rule-set-from-the-quality-profile.md) | Derive the build's Sonar rule set from the quality profile: generated membership, hand-written exceptions, weekly drift check | Proposed |
| [ADR-0062](0062-derive-the-build-rule-set-from-the-quality-profile.md) | Derive the build's Sonar rule set from the quality profile: generated membership, hand-written exceptions, weekly drift check | Accepted |
3 changes: 2 additions & 1 deletion doc/handwritten/for-maintainers/workflows/README.fr.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,8 @@ documentées une seule fois ici plutôt que répétées sur chaque page.
| [`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. |
| [`sonar-profile`](sonar-profile.fr.md) | Chaque nuit : échoue quand la liste de règles C# Sonar committée s'est écartée du profil qualité SonarCloud. Signale, ne répare jamais. |
| [`sonar-profile`](sonar-profile.fr.md) | Chaque semaine : échoue quand la liste de règles C# Sonar committée s'est écartée du profil qualité SonarCloud. Signale, ne répare jamais. |
| [`sonar-gate`](sonar-gate.fr.md) | Chaque nuit : lit le verdict du Quality Gate SonarCloud et échoue s'il est rouge. Ne bloque jamais un merge — l'alarme que le téléversement `sonar` n'a jamais été. |
| [`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
Expand Down
3 changes: 2 additions & 1 deletion doc/handwritten/for-maintainers/workflows/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,8 @@ here instead of being repeated on every page.
| [`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. |
| [`sonar-profile`](sonar-profile.en.md) | Nightly: fails when the committed Sonar C# rule list has drifted from the SonarCloud quality profile. Reports, never repairs. |
| [`sonar-profile`](sonar-profile.en.md) | Weekly: fails when the committed Sonar C# rule list has drifted from the SonarCloud quality profile. Reports, never repairs. |
| [`sonar-gate`](sonar-gate.en.md) | Nightly: reads the SonarCloud Quality Gate verdict and fails when it is red. Never blocks a merge — the alarm the `sonar` upload never was. |
| [`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
Expand Down
106 changes: 106 additions & 0 deletions doc/handwritten/for-maintainers/workflows/sonar-gate.en.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# `sonar-gate` workflow

🌍 🇬🇧 English (this file) · 🇫🇷 [Français](sonar-gate.fr.md)

> Maintainer documentation — part of the [workflow reference](README.md).
> Not part of the user documentation under `doc/`.

**Workflow file:** [`.github/workflows/sonar-gate.yml`](../../../../.github/workflows/sonar-gate.yml)
**Script:** [`tools/sonar-profile/check-gate.sh`](../../../../tools/sonar-profile/check-gate.sh)

## What it is for

It reads the **SonarCloud Quality Gate verdict** every night and fails when it is not green.

Until this existed, the gate was computed by SonarCloud and **enforced by nothing**.
`dotnet-sonarscanner end` uploads the analysis and returns; it neither waits for the gate nor
reads it, so the [`sonar`](sonar.en.md) job is green as soon as the upload succeeds, whatever
the verdict. No GitHub check carried the verdict either — of the 28 checks on a recent pull
request, the only Sonar one was the repository's own analysis job. Two findings typed
VULNERABILITY reached `main` behind that permanently green workflow.

## Why not make the scanner wait instead

`sonar.qualitygate.wait=true` looks like the obvious fix and is the wrong one *as posed*,
because it bundles two decisions: whether `sonar` should be a **required** check, and whether
the gate's verdict should be **read**.

As things stand `sonar` is required and already calls SonarCloud, so an outage already blocks
every merge while a red gate does not. Adding the wait extends that dependency instead of
removing it: the failure mode becomes "nobody can merge because a SaaS is down".

Reading the verdict from a **scheduled** job separates the two. The verdict gets enforced, and
an outage costs a red nightly rather than a frozen repository. That is the combination ADR-0062
recorded as never having been evaluated on its own merits — this workflow is it.

## Why this is not redundant with the build

`build/sonar-profile.globalconfig` enforces the C# rules the `SonarAnalyzer` NuGet package
implements. That is a strict **subset** of what the gate measures, and the gap is not academic:

* **Symbolic-execution rules the package does not run.** Measured: an `S2583` violation sat in
this repository with the rule enforced at `warning` in the generated config, and the local
build reported *nothing*. SonarCloud's engine found it; the analyzer package cannot.
* **Every non-C# family** — `githubactions`, `shell`, `secrets`, `xml`, `json`, `yaml`. Sonar
analyses five languages here; C# is 85% of the lines and none of the others are covered by a
Roslyn analyzer.
* **Coverage, duplication and hotspot review**, which no analyzer can answer at all.

Both findings that last turned this gate red were in those classes: a symbolic-execution bug
and a `githubactions` vulnerability. The build hardening and this check are complements, not
alternatives — and it was the absence of this check that let them sit unnoticed.

## When it runs

- **Nightly**, 04:11 UTC — in the gap left by the weekly jobs, which run Monday between 03:00
and 06:30.
- On demand via **`workflow_dispatch`**.

Nightly, unlike the weekly [`sonar-profile`](sonar-profile.en.md) drift check: a quality profile
moves on a vendor's release cadence, but the gate moves with **every merge**.

It deliberately does **not** run on pull requests. That is the whole point: the verdict is
read and reported, and no merge ever waits on a third party.

## How it runs

One job, `Quality gate verdict`: checkout, then `tools/sonar-profile/check-gate.sh`, which
calls `/api/qualitygates/project_status` and exits non-zero when the status is not `OK`,
listing the failing conditions.

Ratings are translated from the raw `1..5` the API returns to the `A..E` the dashboard shows —
"C" tells a reader where they are, "3" does not — and the message states what a rating worse
than A actually means: reliability is a **Bug**, security a **Vulnerability**, maintainability a
**Code Smell**.

## Permissions & security

`contents: read`, declared **on the job** (Sonar `githubactions:S8264`).

The project is public, so this needs **no secret**. `SONAR_TOKEN` is passed from the same secret
`sonar.yml` uses so the check keeps working the day the project stops being public; a missing
secret is an empty string, which the script treats as unauthenticated. Every request refuses
non-HTTPS on the initial call *and* on redirects, which matters because the authenticated branch
sends the token.

## Handle with care

- **It never blocks a merge, and that is deliberate.** It is a standing alarm, not a gate. A red
gate will produce a red run every night until somebody acts, which is the intended behaviour
and also the way this check can be muted into uselessness.
- **It reads the project, not the branch.** The verdict reflects the last analysis of `main`, so a
fix on an unmerged branch does not turn it green — only merging does. Expect a lag of one
analysis after any fix lands.
- **A rating condition names a class, not a count.** `new_reliability_rating: C` means "at least
one Major bug in the new-code period"; it does not say how many. Follow the link the script
prints to see them.
- **It shares its script directory with `sonar-profile` but answers a different question.** Drift
means "the rule list is stale, regenerate it"; a red gate means "something got through, go look".
Different actions, which is why they are separate workflows on separate cadences.

## Related

- [`sonar`](sonar.en.md) — produces the analysis this reads. It uploads; it has never enforced.
- [`sonar-profile`](sonar-profile.en.md) — the weekly check that the committed rule list still
matches the server's profile.
- [`ci`](ci.en.md) — where the warning ratchet enforces the C# rules the build *can* see.
Loading
Loading