Intégration continue (CI)
Ce document décrit la CI du dépôt Air : les workflows, les runners, et les seuils
de couverture. La CI gate les pull requests et les push sur main.
Workflows
| Workflow | Fichier | Déclencheurs | Rôle |
|---|---|---|---|
ci | .github/workflows/ci.yml | pull_request, push sur main et tags, workflow_dispatch, nightly | Détection de périmètre, tests + clippy + couverture sur x86_64 (speedy), chaîne d’approvisionnement, loom, cross-check aarch64, tests natifs aarch64 hors PR |
docs | .github/workflows/docs.yml | pull_request, push sur main, workflow_dispatch | Contrôles documentaires (registre ADR, index, journal de bord), génération du docbook mdBook + référence API rustdoc, publication sur le VPS (GitHub-hosted) |
Une seule toolchain — partout, épinglée
Le dépôt n’emploie qu’UNE version de la toolchain Rust, et cela vaut pour rustc, pour
clippy, pour rustfmt, et pour le LLVM qui les accompagne. Le canal est déclaré dans
rust-toolchain.toml, et les deux fichiers de rt/ portent le même.
Pourquoi cette règle existe
Une toolchain, c’est un compilateur et son LLVM. Deux toolchains sur une machine, ce sont deux LLVM — et les formats qu’ils échangent ne sont pas compatibles entre eux.
Le 2026-08-15, le dépôt en portait trois : stable 1.96.0 à la racine,
nightly-2026-07-11 sous rt/, et un nightly roulant employé par la mesure des
branches. Les profils de couverture produits par l’une sont devenus illisibles pour
l’autre :
raw profile version mismatch: Profile uses raw profile format version = 11; expected = 10
error: no profile can be merged
La CI ne pouvait plus conclure, et il a fallu une journée pour comprendre — parce que rien
ne disait qu’il y avait plusieurs toolchains. C’était la conséquence d’une montée de nightly
faite à moitié : le tree rt/ avait été monté, le reste laissé en arrière.
Ce qui l’outille
cargo xtask check-toolchain (bloquant en CI) refuse :
- deux
rust-toolchain.tomldéclarant des canaux différents —rustupchoisit le fichier le plus proche, donc deux fichiers discordants font deux toolchains selon le répertoire d’où l’on invoquecargo; - tout canal nommé à l’invocation (
cargo +<canal> …) dansxtask/ou dans les workflows — il court-circuite le pin, et c’est ainsi que le nightly roulant s’était glissé dans la mesure de couverture.
Les citations en commentaire (cargo +nightly fuzz run … dans la documentation) sont
hors champ : la prose a le droit de montrer une commande manuelle.
Les machines doivent suivre
Tout exécuteur, et toute machine qui contribue à une construction, porte cette toolchain
et ses composants (rustfmt, clippy, rust-src, llvm-tools-preview). Une machine
restée en arrière produirait des artefacts que les autres ne savent pas lire — et le
symptôme apparaîtrait loin de la cause, comme le 2026-08-15.
rustup toolchain install <canal> --profile minimal --no-self-update
rustup component add rustfmt clippy rust-src llvm-tools-preview --toolchain <canal>
Vérification que trois machines portent bien la même :
rustc +<canal> --version --verbose | sed -n 's/^LLVM version: //p'
Au 2026-08-15, carbon, hydrogen et raspi-srv-2 portent rustc 1.99.0-nightly (375b1431b 2026-07-10), LLVM 22.1.8 — raspi-srv-2 ne l’avait pas du tout, et c’est pourtant une
machine de construction.
Après une montée de canal, une reconstruction complète est due sur chaque machine : les caches portent des artefacts de l’ancien compilateur, qui ne seront ni réutilisables ni lisibles.
Runners self-hosted
Les crates de la couche 0 sont Linux-only et doivent être validées sur x86_64 ET aarch64 (ADR-014). La répartition a été révisée le 2026-07-07 (décision BDFL, cf. ADR-072) : x86_64 redevient le runner CI primaire — rapide, toolchains pinnées — et l’invariant aarch64 est tenu par deux filets :
cross-check-aarch64—cargo check --target aarch64-…sur speedy : filet compile-time, au premier chef la signedness dec_char(i8sur x86,u8sur aarch64), source de bugs FFI récurrents dans la libc. ~1 min, sur chaque PR ;aarch64-native— compilation + tests natifs sur le Pi : garant du runtime (numéros de syscalls…), uniquement surpush main/ nightly / scellement. Pas sur les PR : le Pi, lent, ne bride plus l’itération.
ADR-037 (« x86 hors CI ») est ainsi
révisé. La barrière x86 pré-merge (cargo xtask barrier sur carbon/speedy)
reste le garde-fou local. Les runners sont enregistrés en service persistant
(svc.sh, démarrage auto au reboot) :
| Runner | Machine | Arche | Labels | Rôle |
|---|---|---|---|---|
speedy | Mac mini Intel (Ubuntu) | x86_64 | self-hosted, Linux, X64, air-x86_64 | Runner CI primaire : changes, test-coverage, supply-chain, loom, cross-check-aarch64 |
neon | Raspberry Pi 4 (Ubuntu) | aarch64 | self-hosted, Linux, ARM64, air-aarch64 | aarch64-native — hors PR (push main, nightly, tag) |
Renommage. Le runner aarch64 s’appelait
raspi-srv-2jusqu’au 2026-09-09 ; la machine a été renomméeneon. Les constats et mesures antérieurs, plus bas, gardent l’ancien nom : ce sont des faits datés, pas des descriptions de l’infrastructure actuelle.Pourquoi. neon est lent : le mobiliser sur chaque PR bridait l’itération sans rien acheter qu’un
cargo checkcroisé n’attrape déjà. carbon et speedy (x86_64 ; carbon 8 c/16 Go, speedy 4 c/8 Go) portent la barrière pré-merge sur deux micro-architectures.
Les toolchains (stable 1.96.0 via rust-toolchain.toml, nightly,
cargo-llvm-cov, cargo-audit, cargo-deny, cargo-machete) persistent sur les
runners. Le workflow les vérifie/installe de façon idempotente (le 1er run après
une mise à jour de toolchain peut être plus lent, surtout sur le Pi).
Note sécurité. Un runner self-hosted exécute le code des PRs. Tant que le dépôt est privé et mono-mainteneur, le risque est faible. À réévaluer à l’arrivée de contributeurs externes (isolation, runners éphémères, ou bascule vers des runners GitHub-hosted pour les PRs externes).
Barrière pré-merge (garde-fou local)
Avant tout push, la barrière complète est exécutée sur speedy ET carbon
via cargo xtask barrier : fmt (stable + nightly) · clippy -D warnings ·
cargo test --workspace · couverture lignes ≥ 96 / branches ≥ 78 en root
(ADR-031) · cargo audit / deny / machete · // SAFETY: · cohérence
Cargo.toml ↔ DEPENDENCIES.md · check-c-surface.
Elle ne remplace plus la CI x86 (qui est revenue depuis ADR-072) : elle la précède, pour ne pas découvrir en CI ce qu’une machine locale voyait déjà, et elle couvre deux micro-architectures x86.
Périmètre : ce qui déclenche quoi
Trois filtres successifs, du plus grossier au plus fin. Tous sont calculés par le
job changes, à partir du seul git diff --name-only (zéro dépendance
d’action, Principe 6).
1. Diff SANS CODE → aucun job de code
Un diff dont aucun fichier ne peut changer le résultat d’un cargo build /
cargo test ne fait tourner ni test-coverage, ni cross-check-aarch64, ni
loom, ni supply-chain, ni aarch64-native (sortie sans_code). Le critère a
deux étages, le premier primant (décision BDFL 2026-08-01) :
- Sources. Tout ajout, modification ou suppression d’un fichier
.rs .c .h .cc .cpp .cxx .hpp .hh .swift .s .asm .capnp, où qu’il soit dans l’arbre : on lance tout. Ce contrôle porte sur le diff brut et court-circuite délibérément l’allowlist de chemins ci-dessous — un.rsd’exemple posé sousdocs/, un helper.csous.github/, ne doivent pas passer pour de la documentation. - Allowlist stricte de chemins.
docs/**,.github/**, points d’entrée racine (README*,CLAUDE.md,AGENTS.md,LICENSE), plus les fichiers inertes écartés en amont (.gitignore,book.toml,.claude/,scripts/,.githooks/README.md,docs.yml). Tout le reste compte comme du code — dontCargo.lock(une bascule de dépendance régresse le code sans qu’aucun.rsne bouge),rust-toolchain.toml,deny.toml,.gitattributes(peut changer le contenu au checkout) et.githooks/commit-msg.
Exception dans docs/ : COVERAGE-EXCEPTIONS.md pilote le gate de
couverture — le toucher relance la mesure.
La contrepartie.
.github/**figure dans l’allowlist parce qu’un workflow définit ce que la CI exécute, pas ce quecargoproduit. Le risque propre à ce répertoire — changer la décision de périmètre elle-même — est couvert parscripts/test-ci-perimetre.sh, étape obligatoire du jobchanges: il extrait la logique de décision deci.ymlet la rejoue sur ~30 diffs simulés, en quelques secondes, sans compilation. Le test existait mais aucun workflow ne le lançait — la garde n’était gardée par rien.Limite assumée : ce gate valide la décision de périmètre, pas le contenu des jobs de code. Un seuil abaissé à l’intérieur de
test-coveragepasserait sous son radar ; cela reste couvert par la relecture humaine et par la première PR de code qui suit.
2. Forçage du mode complet (full_forced)
Du code a bougé, mais un fichier structurant hors crate l’accompagne (CI,
xtask/, rt/, manifeste ou lock racine, toolchain, deny.toml, registre de
couverture), ou un fichier n’est rattachable à aucune couche : on ne scope
rien. Cf. Filtrage de la re-vérification.
3. Sélection par couche (couche0/couche1/couche2)
Du code a bougé, sans fichier structurant : tests et couverture sont restreints aux couches réellement touchées. Cf. Sélection par couche.
Jobs
test-coverage (x86_64 — speedy)
Sauté si sans_code. Dans l’ordre :
cargo fmt --all -- --checkcargo clippy --all-targets --all-features -- -D warnings(un lint peut être arch-spécifique, p. ex.cast_losslessobservé sur aarch64).cargo test --workspace(lib + intégration + doctests).- Couverture :
cargo xtask couvrable-vide --strict(mode complet) ou--layer N(couche touchée). Le gate lance lui-mêmellvm-coven root — lignes (--show-missing-lines) puis branches (+nightly --lib --branch) — et applique les trois volets : réconciliation ligne à ligne des crates réconciliés, plancher lignes agrégat pour les autres, et non-régression des branches contrextask/couverture-reference.toml(cf. Branches : non-régression). Les anciennes étapes--fail-under-lines 96et « plancher branches 78 en shell » (cargo-llvm-covn’expose pas de--fail-under-branches) ont été remplacées par ce gate unique.
changes (x86_64 — speedy)
Le job dont tous les autres dépendent. Deux étapes :
scripts/test-ci-perimetre.sh— gate de la garde elle-même (cf. Périmètre). Quelques secondes.detect— calculesans_code,full_forcedetcouche0/1/2à partir du seulgit diff --name-only, et les expose en sorties de job.
Son échec bloque la PR sans exiger de réglage de protection de branche, puisque
tous les autres jobs le déclarent en needs:.
supply-chain (x86_64 — speedy)
Sauté si sans_code.
cargo audit · cargo deny check · cargo machete · cargo xtask check-syscalls · cargo xtask check-c-surface · cargo xtask audit-exceptions.
Les gates xtask sont gravés en outillage (cf.
outillage-xtask.md) : check-syscalls compare les numéros
de syscalls déclarés dans air-sys-syscall aux headers uapi des deux arches
(la classe d’erreur qui nous a piégés) ; audit-exceptions valide le format /
la taxonomie ADR-035 du registre COVERAGE-EXCEPTIONS.md et recompte son
récapitulatif (anti-dérive du décompte). Rapides, sans root, exit ≠ 0 sur
violation.
loom (x86_64 — speedy)
Sauté si sans_code. Vérification déterministe de concurrence (cfg(loom))
sur les structures partagées : explore les entrelacements plutôt que d’espérer
les rencontrer.
cross-check-aarch64 (x86_64 — speedy)
Sauté si sans_code. cargo check --target aarch64-unknown-linux-gnu : filet
compile-time de l’invariant tier-1 (ADR-014), ~1 min, sur chaque PR. Attrape
au premier chef la signedness de c_char.
aarch64-native (aarch64 — neon)
Sauté si sans_code, et sur toutes les PR. Ne tourne que sur push main,
nightly et tag : compilation + tests natifs, garants du runtime (numéros de
syscalls, comportements noyau).
Politique « zéro surface C/C++ » (audit 082)
Air est aujourd’hui 100 % Rust pur — aucune dépendance ne compile ou ne lie du C/C++. Cet état est figé en invariant CI, par deux verrous complémentaires :
deny.toml[bans]: ban nominatif des vecteurs C connus —cc,cmake,bindgen,pkg-config,aws-lc-sys,ring. (cargo-denyne sait pas globber*-sys, d’où le second verrou.)cargo xtask check-c-surface: garde-fou générique — échoue si une crate dont le nom finit par-sys(convention des bindings FFI vers une lib C) entre dans l’arbre résolu (cargo tree -e normal,build). Parseur testé sur fixtures ; faux positif évité sur les crates Airair-sys-types/air-sys-syscall(suffixes-types/-syscall, pas-sys). Câblé ici (jobsupply-chain) et danscargo xtask barrier.
Une réintroduction future de surface C (p. ex. ring si/quand air-tls
adopte un provider crypto) devra être une exception NOMMÉE dans
docs/EXCEPTIONS.md — justification + plan de sortie —
suivant la discipline ADR-024/ADR-034. Aucune entrée EXCEPTIONS.md
aujourd’hui : Air est zéro-C.
Workflow docs — les gates documentaires
Tourne sur chaque PR, sur un runner GitHub-hosted (mdBook et cargo doc
n’exigent pas les runners self-hosted). Il porte quatre contrôles bloquants, tous
en bibliothèque standard seule (Principe 6), puis la publication :
| Étape | Ce qu’elle interdit |
|---|---|
scripts/generer-registre-adrs.py --check | Un ADR ajouté sans entrée de statut, une version FR ou EN manquante, un registre non régénéré |
scripts/generer-index-general.py --check | Un INDEX.md dérivé du réel (sceaux de couche, nombre de crates, volumétrie) |
scripts/verifier-journal.py --explique | Un journal de bord en retard de plus de 10 PR, une entrée mal formée ou hors ordre anté-chronologique |
scripts/build-docs.sh | Un lien cassé, un SUMMARY.md désynchronisé |
Les trois premiers existent parce que les documents correspondants avaient
dérivé du réel : registre tenu à la main, index annonçant couche-1-v1.9
quand le sceau réel était v3.8, journal abandonné pendant 263 PR. La règle du
dépôt est qu’un document qui peut dériver doit être généré ou vérifié, jamais
tenu à la main et espéré juste.
fetch-depth: 0 est indispensable : l’index tire les sceaux de couche des
tags git, et le contrôle du journal compte les PR dans l’historique. Sur un clone
superficiel, ces contrôles échouent bruyamment plutôt que de conclure à tort.
La publication (rsync vers le VPS box2, docs.air-desktop.org) n’a lieu que
sur push main — un workflow_dispatch depuis une branche construit le site
sans le publier.
Cache de compilation — sccache
Le job test-coverage repart d’un target/ propre à chaque run
(actions/checkout clean: true sur les runners persistants). Sans cache, la
couche 0 scellée (couche-0-v1) est recompilée intégralement à chaque
PR — coûteux, surtout sur le Pi. On branche sccache comme RUSTC_WRAPPER :
son store vit hors de target/ (SCCACHE_DIR=$HOME/.cache/sccache), donc
survit au git clean et repeuple en cache-hits les crates inchangées.
- Installation (
Ensure sccache, idempotente) : binaire pré-bâti officiel (musl, SHA256 épinglé —v0.10.0) pour l’arche du runner, sans compilation (important sur le Pi) ; fallbackcargo install sccache --lockedsignalé (::warning::) si le téléchargement/checksum échoue. - Périmètre : seules les étapes non-root (
fmt/clippy/test) sont cachées (RUSTC_WRAPPER=sccache,SCCACHE_CACHE_SIZE=5G). C’est là qu’est le gain « recompilation ». - Étapes de couverture en root — choix (b) : NON cachées. Les passes
llvm-covtournent ensudo(ADR-031) ; sous root, sccache viserait le cache de root (misses / permissions), et les builds-C instrument-coveragese cachent mal. On désactive donc explicitement le wrapper sur ces étapes (env … "RUSTC_WRAPPER=" cargo llvm-cov …). La mécanique de déterminisme de couverture (purge.profraw, restauration de propriété detarget/) est inchangée : le store sccache est distinct detarget/. (La re-mesure de la couche 0 scellée sera traitée séparément — ADR-036 filtrage par chemin.) - Mesure : une étape finale
sccache --show-stats(--zero-statsen début de run) chiffre le taux de hit.
Sain vis-à-vis d’ADR-025. Un cache = mémoïsation « mêmes sources + même
toolchain → mêmes octets », vérifiée par hachage des entrées : exactement les
mêmes builds, plus vite. Garde-fou repro : le gate cargo xtask repro
(preuve de déterminisme par double-build) neutralise tout RUSTC_WRAPPER
(RUSTC_WRAPPER=/RUSTC_WORKSPACE_WRAPPER= dans son environnement de build) —
un cache-hit ne peut donc pas masquer une non-reproductibilité. repro
n’est par ailleurs câblé dans aucun job CI.
Filtrage par chemin de la re-vérification d’une couche scellée (ADR-036)
La couche 0 est scellée (couche-0-v1) : ses sources sont gelées. Re-mesurer
intégralement sa couverture (lignes + branches, en root, coûteux surtout sur le
Pi) à chaque PR couche 1 n’achète rien — sauf face à la dérive de
toolchain (un bump nightly/stable peut changer clippy/llvm-cov à sources
identiques). ADR-036
grave la politique ; la CI l’implémente ainsi :
-
Job
changes: calcule les booléens de périmètre viagit diff --name-onlycontre la base de la PR (zéro nouvelle dépendance d’action, Principe 6) — cf. la section Sélection par couche qui étend ce job (couche0/couche1/couche2/full_forced). -
Re-vérification COMPLÈTE (étapes couverture lignes/branches en root, inchangées) si et seulement si — variable de job
FULL:couche0 == true(PR touchant la couche 0 : tout en dépend) ;full_forced == true(CI /xtask/rt// manifeste ou lock racine / toolchain /deny.toml/ registre de couverture, ou fichier non rattachable) ;- tag (release ADR-142 ou scellement de couche) et
workflow_dispatchmanuel — garde-fou (b’) ; - planifié NIGHTLY (
schedule: cron '0 4 * * *') — garde-fou (c) : détecte une dérive d’environnement runner sans activité de PR.
Retiré (ADR-143 §4, décision BDFL) : le déclencheur
push/merge versmain. La PR a déjà vérifié sa couche en pré-merge ; refaire une couverture complète à chaque merge est redondant. Le filet devient nightly + tag — la dérive lente est attrapée au plus tard le lendemain. -
Sinon (PR scopée) :
fmt,clippys’exécutent toujours sur tout le workspace ;cargo testet la couverture sont scopés aux couches touchées (section suivante). Le job n’est donc pas vert « à vide ».
Aucun job fuzz n’existe en CI aujourd’hui (le fuzzing reste un cargo +nightly fuzz run manuel) ; la politique vaut pour la couverture. Un rouge
sur l’une des exécutions complètes rouvre le sceau (traitement prioritaire).
Le tag couche-0-v1 reste la référence d’autorité.
Sélection par couche (ADR-143)
Le job test-coverage prenait 15-20 min par PR, l’essentiel venant de
cargo test --workspace — dont les interop réels (SFTP ≥ 100 Mio, ssh,
conformité ABI C), tous en couche 2. ADR-143 restreint le périmètre à ce
que la PR touche réellement.
Détection (job changes). Chaque fichier modifié est rattaché à son crate
(crates/<nom>/…) puis à sa couche, lue dans [package.metadata.air] layer
de son Cargo.toml — la même source de vérité que cargo xtask check-layers
et couvrable-vide --layer N, jamais une liste en dur. Sorties :
couche0/couche1/couche2. Tout fichier structurant hors crate
(.github/**, xtask/**, rt/**, fuzz/**, tools/**, .cargo/**,
rust-toolchain.toml, Cargo.toml/Cargo.lock racine, deny.toml,
docs/COVERAGE-EXCEPTIONS.md) — et tout fichier qui n’est ni documentation
ni rattachable à une crate classée — met full_forced=true : mode
complet, on ne devine pas.
Tests scopés. Mode complet → cargo test --workspace, à l’identique. Sinon :
| Couche touchée | cargo test porte sur |
|---|---|
| 0 | (escalade en mode complet) |
| 1 | crates couches 1 + 2 |
| 2 | crates couche 2 seuls |
Sûreté : une couche basse non touchée a un code et des tests
identiques ⇒ ses tests passent inchangés, inutile de les rejouer. On
sur-teste au plus la couche touchée entière (pas de calcul de
dépendants-inverses — écarté par ADR-143). La liste vient de
cargo xtask crates-of-layer N [--and-above].
Couverture scopée. Pour chaque couche touchée N, la CI lance
cargo xtask couvrable-vide --strict --layer N : llvm-cov reçoit -p <crate>
pour les seules crates de la couche N, puis les mêmes règles s’appliquent
(réconciliation ligne à ligne des crates réconciliés, plancher agrégat pour les
autres, plancher branches). C’est une restriction du mode complet, pas une
garantie différente. La mécanique de déterminisme d’ADR-031 (purge
.profraw, sudo -n, restauration de propriété) s’applique aussi au mode
par couche : llvm-cov y tourne également en root.
Limite assumée. Une couche mesurée seule ne bénéficie plus des tests des
couches supérieures : sa mesure peut être plus sévère que dans le mode
complet (faux échec possible, jamais faux succès — direction conservatrice).
C’est pourquoi la couche 0 touchée escalade en mode complet au lieu d’un
--layer 0 : sa couverture provient largement des tests des couches au-dessus.
Couverture : seuils et mesure
Le standard de la couche 0 est 100 % lignes + branches hors exceptions. Cet
invariant n’est pas mécaniquement vérifiable : les exceptions légitimes
(privilège, feature kernel, enfant forké, EFAULT-safe, structurel,
valeur-impossible) sont documentées en prose dans
COVERAGE-EXCEPTIONS.md et revues humainement à chaque
PR.
La CI applique donc un plancher anti-régression sur la couverture brute (elle échoue si la couverture passe sous le seuil), pas un test d’égalité à 100 %.
Mesure en root (ADR-031). Les étapes
llvm-covdu jobtest-coverages’exécutent en root (sudo -n) afin que les tests d’intégration privilégiés (bpf()/CAP_BPF,uinput, perf, cgroup, bpffs) s’exécutent réellement sous instrumentation. La familleebpf, quasi-tout-privilégiée, plafonnerait sinon ~74 % en non-root. Prérequis :NOPASSWDsur les deux runners self-hosted ; une étape finale rétablit la propriété du workspace. Modèle de confiance : dépôt privé / mono-mainteneur (cf. ADR-031).
Hygiène des artefacts root — deux filets. Mesurer en root a un effet de bord : les artefacts écrits sous
target/(llvm-cov-target) appartiennent à root, alors que legit clean -ffdxdu checkout tourne en non-root. Un artefact root oublié bloque donc tous les runs suivants enEACCES— sur tous les jobs, le workspace_work/<dépôt>/<dépôt>étant partagé — jusqu’à unrm -rfmanuel sur le runner. Deux filets, complémentaires et non redondants :
- En fin de job (
if: always()) :chown -Rdu workspace entier et deCARGO_HOME(les étapes root tournent ensudo -E, donc uncargoroot qui télécharge une dépendance écrit dans le registre de l’utilisateur).- Avant le checkout, sur chaque job : reprise de propriété du workspace. C’est le filet qui compte, car le filet 1 ne survit pas à tout —
always()ne couvre ni un runner tué, ni une coupure ; et un run annulé (cancel-in-progress, déclenché par un simple re-push sur une PR) laisse une fenêtre. Idempotent, no-op quand rien ne traîne, tolérant sisudo -nmanque.Le filet 2 est une étape
run:inline et non une action composite locale : une action locale exigerait un checkout préalable — précisément l’étape qui échoue.
--exclude xtask. La crate d’outillagextask/(dev-tooling, membre du workspace pour l’aliascargo xtask) n’est pas du code Air livré et n’est pas soumise au 100 % : les étapesllvm-covl’excluent (--workspace --exclude xtask). Voir outillage-xtask.md. (La crate couche 1air-base-libétait exclue tant qu’elle n’était qu’un squelette Passe 1 ; depuis son implémentation Passe 2, elle est mesurée à 100 % comme couche fondatrice — exclusion retirée.) (Idemair-crypto: exclusion Passe 1 retirée depuis son implémentation Passe 2 — mesurée comme couche fondatrice.) (Idemair-filesystem: exclusion Passe 1 retirée depuis son implémentation Passe 2 — mesurée comme couche fondatrice ; les rares bras non couverts sont des gardes défensives STRUCTUREL/ENV documentées in-code, ADR-035.) (Idemair-memory: exclusion Passe 1 retirée depuis son implémentation Passe 2 —tracker/arena/pool/slab/backingmesurés ; aucune fonctionunsafeexposée, l’uniqueunsafede l’arène prouvé sans UB sous Miri.)
air-runtime— MESURÉ (étape 6 phase B), TARGET-ONLY par fichier. La crate couche 1air-runtime(runtimeAirRuntime: TCB/TLS/relocation/environnement/ spawn/fork, ADR-052) est réalisée et mesurée : la façadeAirRuntime(errno/set_errno/current_tid) et les modules host-testables (args/env/reloc-parser/fork/lib) sont mesurés à 100 %. L’exclusion en bloc--exclude air-runtimea été retirée (amendement ADR-035 § TARGET-ONLY). Seuls ses 4 fichiers TARGET-ONLY —thread_control_block.rs,thread_local_storage.rs,thread.rs,start.rs— sound uniquement sur*-linux-air(registre TLS, TCB, spawnCLONE_SETTLS, bootstrapcrt0) sont ignorés par fichier (--ignore-filename-regex, précédent_capnp.rs) ; les quelques lignes target-only résiduelles dereloc.rs/fork.rssont consignées au registre COVERAGE-EXCEPTIONS.md (catégorie TARGET-ONLY). Preuve on-target : selftestrt/crates/airrt-selftest(exit 42, 2 arches).
- Lignes :
cargo llvm-cov --workspace --exclude xtask(tous les targets ;air-runtimemesuré, 4 fichiers TARGET-ONLY ignorés par regex). - Branches :
cargo +nightly llvm-cov --workspace --exclude xtask --lib --branch. Le--libest obligatoire : sans lui, le binaire de test d’intégration lie la lib sans#[cfg(test)]et colle une région fantôme[True:0, False:0]sur chaque branche (artefact prouvé, pas une vraie branche source).
Baseline et seuils
Baseline mesurée le 2026-06-01 (HEAD f046603) :
| Arche | Lignes | Branches |
|---|---|---|
| speedy (x86_64) | 96.61 % | 78.79 % |
| raspi-srv-2 (aarch64) | 96.49 % | 78.97 % |
Les deux arches diffèrent légèrement (divergence d’environnement documentée :
mlockall réussit sur speedy, échoue EPERM sur le Pi). Plancher commun retenu,
un cran sous la plus basse valeur de chaque métrique :
- lignes :
cargo llvm-cov --workspace --fail-under-lines 96(flag natif). - branches : plancher 78… abandonné — voir la section suivante, où il est remplacé par une non-régression contre une référence versionnée.
Le plancher lignes a été recalibré à 96 sur mesure empirique
(2026-07-01, post-loom) : 5 passes de cargo llvm-cov --workspace donnent
96.65–96.66 % de lignes couvertes, variance 0.01 %. La variance
run-to-run qui avait motivé la descente à 94 (#189 — 95.89 % vs 96.12 %
observés à l’époque, ~0 % de marge sous 96) a disparu ; 96 laisse ~0.65 % de
marge sous le min observé pour une variance négligeable. On rétablit donc un
filet anti-régression serré (une régression réelle coûte plusieurs points)
sans risque de flake. La vraie garantie reste la couverture per-crate 100 % des
couches 0/1 (réconciliation déterministe couvrable-vide contre
COVERAGE-EXCEPTIONS.md, inchangée) ; ce plancher global n’est qu’un filet
grossier (ADR-031).
Relever encore ce seuil quand la couverture brute monte durablement (p. ex. après ajout de tests) — en gardant une petite marge sous le min observé.
Depuis ADR-037, la CI applique ces planchers sur aarch64 ; les mêmes planchers sont vérifiés sur x86_64 par la barrière pré-merge (
cargo xtask barrier) sur speedy et carbon.
Branches : non-régression contre une référence versionnée
En mode COMPLET, la couverture de branches n’est plus comparée à un
plancher absolu mais à une référence versionnée, xtask/couverture-reference.toml
(le fichier porte, en commentaires, son contexte de mesure — un nombre sans son
contexte est exactement ce qui a laissé le 78 dériver) :
- échec si
mesuré < référence − 0.10(tolérance) — le message nomme le delta (« 77.20 % < référence 77.72 − 0.10 : régression de 0.52 point ») ; - message d’avancement si
mesuré > référence + 0.25: la référence peut être relevée. C’est le ratchet, et c’est la seule façon dont elle monte ; - succès entre les deux.
Pourquoi. Le plancher absolu 78 avait dérivé : plus aucune mesure ne
l’atteignait, dans aucun environnement — main lui-même mesurait 77.42 %
en local et 77.72 % en CI. Le gate bloquait donc toutes les PR, y compris
celles qui ne touchent pas la métrique. Personne ne l’avait vu, parce qu’un
agrégat sur ~70 crates ne nomme aucun coupable : il dit « 77.4 < 78 » et
laisse chercher. Une non-régression, elle, ne peut pas dériver : la référence est
un fait mesuré, versionné, relu en revue, et une PR n’a qu’un devoir — ne pas
dégrader.
Comment on relève la référence. Quand le gate propose un relèvement, on édite le fichier : la valeur, sa date et son SHA. Elle ne se baisse jamais à la main — une baisse est une régression, elle se corrige par des tests.
Le piège : la mesure locale n’est pas la mesure CI. ~0,3 point d’écart selon l’environnement (root ou non, tests gatés présents ou absents, outils installés), sans aucun changement de code — d’où la tolérance de 0.10, prise un cran sous cette variance : assez pour ne pas rougir sur du bruit, assez peu pour attraper une vraie dégradation. La référence se relève donc en CI et nulle part ailleurs : y poser un chiffre local ferait rougir la CI sans raison.
Alternative rejetée : recalculer main à chaque PR. Plus pur (aucune valeur à
tenir à jour), mais cela doublerait un job de 22 minutes pour comparer deux
mesures dont on connaît déjà l’une. La référence versionnée donne la même garantie
anti-régression pour un coût nul, au prix d’une ligne à relire en revue.
Périmètre. Cette mécanique porte sur le mode complet (--workspace) et, depuis
le 2026-08-19, sur la couche 2 — chacun sur sa propre clé du fichier de référence
(workspace, layer2), les deux exigées ensemble. --layer 0|1 garde son plancher
absolu (78, valeur historique) : ses branches sont de toute façon vérifiées à 100 % par
la réconciliation, il n’y a rien à y laisser dériver. Le plancher lignes agrégat
(90 %) est inchangé.
Pourquoi la couche 2 a changé de régime. Son plancher absolu valait 75, « un cran
sous » une mesure de 77,16 % prise le 2026-07-31 sur 44 crates. La couche en
comptait 52 le 2026-08-19, pour 74,79 % : le gate interdisait main lui-même.
Nul ne l’avait vu, parce que la mesure par couche ne tourne que si une PR touche cette
couche — la première à le faire depuis des jours est tombée dessus. C’est le défaut que
ce document annonçait déjà pour le workspace : un plancher absolu dérive sans que
personne le voie. (Reste sur un 78 absolu : la barrière locale cargo xtask barrier,
et --layer 0|1.)
Déterminisme : un gate qui ne rougit que sur une vraie régression
Un gate de couverture qui échoue au hasard est pire qu’aucun gate. Deux
sources de non-déterminisme ont été traitées (branche fix/ci-flaky-pidfd-coverage) :
1. Course de réutilisation de fd dans les tests pidfd_* (corrigée)
Trois tests d’air-sys-syscall (pidfd_drop_closes_underlying_fd,
pidfd_send_signal_on_closed_pidfd_returns_ebadf,
pidfd_getfd_on_closed_pidfd_returns_ebadf) prouvent que le Drop du wrapper
RAII ferme le fd sous-jacent, en ré-observant le numéro fermé : le kernel
doit répondre EBADF. Comme cargo test exécute en parallèle, un autre
thread peut allouer un fd qui réutilise ce numéro entre le drop et
l’observation → le fd redevient valide → faux négatif. L’instrumentation de
couverture élargit la fenêtre, ce qui faisait rougir cargo llvm-cov par
intermittence (rare en mesure naturelle, mais reproductible sous amplification :
~11 % d’observations « réutilisées » avec 8 threads de churn et une fenêtre
élargie).
Il s’agit d’un défaut d’observation de test, pas d’une course en
production : le contrat RAII (close(fd) au Drop) est correct ; PidFd est
un PidFd(OwnedFd) sans Drop propre. La correction est côté test, sans
dépendance externe ni --test-threads=1, par double défense par construction :
- fd HAUT non réutilisable : on duplique le pidfd (
dup_fd) vers un numéro proche deRLIMIT_NOFILE(min(soft - 1, 4096)) et on observe la fermeture de CE numéro. Une allocation concurrente prend toujours le plus petit fd libre, jamais ce numéro haut → réutilisation par les autres tests impossible. - verrou
FD_OBSERVATION_LOCK: sérialise les trois tests-frères entre eux, pour qu’ils ne visent pas le même numéro haut au même instant (sinon la course se déplacerait simplement sur le fd haut).
2. Bruit .profraw (purge + résidu bénin documenté)
- Le step Clean coverage artifacts (
cargo llvm-cov clean --workspace) purge les.profraw/.profdatarésiduels avant chaque mesure (le workspace persiste entre runs sur les runners self-hosted). - Des
LLVM Profile Error: Permission deniedpeuvent subsister, émis par les enfants forkés des tests landlock/seccomp qui restreignent leur propre accès FS avant le flush du profil (cf.flush_child_coverage, et les exceptionsCHILD-EXITdeCOVERAGE-EXCEPTIONS.md). Ces messages sont bénins : ils polluent éventuellement la sortie mais ne font pas échouer le step (les profils utiles sont flushés avant la restriction). On ne cherche pas à les masquer par un contournement fragile.
Dette connue — enforcement des status checks
Sur GitHub Free pour un dépôt privé d’organisation, l’API de protection de branche (rendre ces checks « required » au merge) peut être indisponible (cf. JOURNAL, dette « protections de branche »). Conséquence : la CI tourne et affiche son statut sur chaque PR, mais le blocage dur du merge en cas d’échec n’est pas garanti côté GitHub tant que le dépôt est Free/privé.
Mitigation actuelle : discipline du mainteneur (ne pas merger une PR rouge). Résolution prévue : à l’ouverture publique (dépôt public Free débloque les protections) ou via un plan payant.