Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

WorkflowFichierDéclencheursRôle
ci.github/workflows/ci.ymlpull_request, push sur main et tags, workflow_dispatch, nightlyDé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.ymlpull_request, push sur main, workflow_dispatchContrô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 :

  1. deux rust-toolchain.toml déclarant des canaux différentsrustup choisit le fichier le plus proche, donc deux fichiers discordants font deux toolchains selon le répertoire d’où l’on invoque cargo ;
  2. tout canal nommé à l’invocation (cargo +<canal> …) dans xtask/ 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-aarch64cargo check --target aarch64-… sur speedy : filet compile-time, au premier chef la signedness de c_char (i8 sur x86, u8 sur 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 sur push 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) :

RunnerMachineArcheLabelsRôle
speedyMac mini Intel (Ubuntu)x86_64self-hosted, Linux, X64, air-x86_64Runner CI primaire : changes, test-coverage, supply-chain, loom, cross-check-aarch64
neonRaspberry Pi 4 (Ubuntu)aarch64self-hosted, Linux, ARM64, air-aarch64aarch64-nativehors PR (push main, nightly, tag)

Renommage. Le runner aarch64 s’appelait raspi-srv-2 jusqu’au 2026-09-09 ; la machine a été renommée neon. 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 check croisé 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) :

  1. 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 .rs d’exemple posé sous docs/, un helper .c sous .github/, ne doivent pas passer pour de la documentation.
  2. 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 — dont Cargo.lock (une bascule de dépendance régresse le code sans qu’aucun .rs ne 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 que cargo produit. Le risque propre à ce répertoire — changer la décision de périmètre elle-même — est couvert par scripts/test-ci-perimetre.sh, étape obligatoire du job changes : il extrait la logique de décision de ci.yml et 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-coverage passerait 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 :

  1. cargo fmt --all -- --check
  2. cargo clippy --all-targets --all-features -- -D warnings (un lint peut être arch-spécifique, p. ex. cast_lossless observé sur aarch64).
  3. cargo test --workspace (lib + intégration + doctests).
  4. Couverture : cargo xtask couvrable-vide --strict (mode complet) ou --layer N (couche touchée). Le gate lance lui-même llvm-cov en 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 contre xtask/couverture-reference.toml (cf. Branches : non-régression). Les anciennes étapes --fail-under-lines 96 et « plancher branches 78 en shell » (cargo-llvm-cov n’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 :

  1. scripts/test-ci-perimetre.sh — gate de la garde elle-même (cf. Périmètre). Quelques secondes.
  2. detect — calcule sans_code, full_forced et couche0/1/2 à partir du seul git 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-deny ne 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 Air air-sys-types / air-sys-syscall (suffixes -types/-syscall, pas -sys). Câblé ici (job supply-chain) et dans cargo 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 :

ÉtapeCe qu’elle interdit
scripts/generer-registre-adrs.py --checkUn 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 --checkUn INDEX.md dérivé du réel (sceaux de couche, nombre de crates, volumétrie)
scripts/verifier-journal.py --expliqueUn journal de bord en retard de plus de 10 PR, une entrée mal formée ou hors ordre anté-chronologique
scripts/build-docs.shUn 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) ; fallback cargo install sccache --locked signalé (::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-cov tournent en sudo (ADR-031) ; sous root, sccache viserait le cache de root (misses / permissions), et les builds -C instrument-coverage se 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é de target/) est inchangée : le store sccache est distinct de target/. (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-stats en 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 via git diff --name-only contre 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 :

    1. couche0 == true (PR touchant la couche 0 : tout en dépend) ;
    2. full_forced == true (CI / xtask / rt/ / manifeste ou lock racine / toolchain / deny.toml / registre de couverture, ou fichier non rattachable) ;
    3. tag (release ADR-142 ou scellement de couche) et workflow_dispatch manuel — garde-fou (b’) ;
    4. 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 vers main. 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, clippy s’exécutent toujours sur tout le workspace ; cargo test et 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éecargo test porte sur
0(escalade en mode complet)
1crates couches 1 + 2
2crates 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-cov du job test-coverage s’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 famille ebpf, quasi-tout-privilégiée, plafonnerait sinon ~74 % en non-root. Prérequis : NOPASSWD sur 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 le git clean -ffdx du checkout tourne en non-root. Un artefact root oublié bloque donc tous les runs suivants en EACCES — sur tous les jobs, le workspace _work/<dépôt>/<dépôt> étant partagé — jusqu’à un rm -rf manuel sur le runner. Deux filets, complémentaires et non redondants :

  1. En fin de job (if: always()) : chown -R du workspace entier et de CARGO_HOME (les étapes root tournent en sudo -E, donc un cargo root qui télécharge une dépendance écrit dans le registre de l’utilisateur).
  2. 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 si sudo -n manque.

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’outillage xtask/ (dev-tooling, membre du workspace pour l’alias cargo xtask) n’est pas du code Air livré et n’est pas soumise au 100 % : les étapes llvm-cov l’excluent (--workspace --exclude xtask). Voir outillage-xtask.md. (La crate couche 1 air-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.) (Idem air-crypto : exclusion Passe 1 retirée depuis son implémentation Passe 2 — mesurée comme couche fondatrice.) (Idem air-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.) (Idem air-memory : exclusion Passe 1 retirée depuis son implémentation Passe 2 — tracker/arena/pool/slab/backing mesurés ; aucune fonction unsafe exposée, l’unique unsafe de l’arène prouvé sans UB sous Miri.)

air-runtime — MESURÉ (étape 6 phase B), TARGET-ONLY par fichier. La crate couche 1 air-runtime (runtime AirRuntime : TCB/TLS/relocation/environnement/ spawn/fork, ADR-052) est réalisée et mesurée : la façade AirRuntime (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-runtime a été retirée (amendement ADR-035 § TARGET-ONLY). Seuls ses 4 fichiers TARGET-ONLYthread_control_block.rs, thread_local_storage.rs, thread.rs, start.rssound uniquement sur *-linux-air (registre TLS, TCB, spawn CLONE_SETTLS, bootstrap crt0) sont ignorés par fichier (--ignore-filename-regex, précédent _capnp.rs) ; les quelques lignes target-only résiduelles de reloc.rs/fork.rs sont consignées au registre COVERAGE-EXCEPTIONS.md (catégorie TARGET-ONLY). Preuve on-target : selftest rt/crates/airrt-selftest (exit 42, 2 arches).

  • Lignes : cargo llvm-cov --workspace --exclude xtask (tous les targets ; air-runtime mesuré, 4 fichiers TARGET-ONLY ignorés par regex).
  • Branches : cargo +nightly llvm-cov --workspace --exclude xtask --lib --branch. Le --lib est 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) :

ArcheLignesBranches
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 78abandonné — 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 de RLIMIT_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/.profdata résiduels avant chaque mesure (le workspace persiste entre runs sur les runners self-hosted).
  • Des LLVM Profile Error: Permission denied peuvent 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 exceptions CHILD-EXIT de COVERAGE-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.