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 | Tests + clippy + couverture sur l’arche ARM (raspi) + chaîne d’approvisionnement. x86 validé hors CI (barrière pré-merge, ADR-037) |
docs | .github/workflows/docs.yml | pull_request, push sur main | Génération du docbook mdBook + référence API rustdoc (GitHub-hosted) |
Runners self-hosted
Les crates de la couche 0 sont Linux-only et doivent être validées sur
x86_64 ET aarch64 (ADR-014). Depuis
ADR-037, ce découpage est réparti ainsi :
la CI ne tourne que sur le runner ARM (raspi-srv-2), et l’x86 est validé
hors CI par la barrière pré-merge (cargo xtask barrier) sur speedy ET
carbon. Les runners sont enregistrés en service persistant (svc.sh,
démarrage auto au reboot) :
| Runner | Machine | Arche | Labels | Rôle |
|---|---|---|---|---|
raspi-srv-2 | Raspberry Pi 4 (Ubuntu) | aarch64 | self-hosted, Linux, ARM64, air-aarch64 | Seul runner CI (ARM) |
speedy | Mac mini Intel (Ubuntu) | x86_64 | self-hosted, Linux, X64, air-x86_64 | Enregistré, hors workflows — réactivation à l’ouverture publique |
Pourquoi. raspi-srv-2 est lent → réservé au seul rôle CI ARM. speedy et carbon (x86_64 ; carbon 8 c/16 Go, speedy 4 c/8 Go) sont pilotés comme exécuteurs de tâches ; la barrière x86 pré-merge tourne sur les deux (couverture x86 sur deux micro-architectures). Cf. ADR-037.
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).
Validation x86 pré-merge (ADR-037)
L’x86 n’étant plus dans la CI, avant tout merge sur main 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. Ce sont exactement les contrôles de l’ancien runner x86.
Un merge n’a lieu que si les deux machines x86 sont vertes et la CI ARM est verte — préservant l’invariant ADR-014 (x86_64 ET aarch64). À l’ouverture publique, un gate CI x86 sera rebranché (Décision 3 d’ADR-037).
Jobs
test-coverage (aarch64 seul — ADR-037)
Sur l’arche ARM — le x86 suit la même séquence hors CI via cargo xtask barrier (ADR-037) —, 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 lignes :
cargo llvm-cov --workspace --fail-under-lines 96. - Couverture branches :
cargo +nightly llvm-cov --workspace --lib --branch --summary-only, puis extraction du % de la ligneTOTALet application du plancher (78) en shell —cargo-llvm-covn’expose pas de--fail-under-branches(seuls--fail-under-lines/-functions/-regions).
supply-chain (runner ARM)
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.
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.
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 un booléencouche0viagit diff --name-onlycontre la base de la PR (zéro nouvelle dépendance d’action, Principe 6). Globs conservateurs (sur-déclencher plutôt que sous-déclencher) :crates/air-sys-types/**,crates/air-sys-syscall/**,rust-toolchain.toml,Cargo.lock,Cargo.toml(racine),.github/workflows/**,deny.toml,docs/COVERAGE-EXCEPTIONS.md. - 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 / toolchain / CI / politique) ;push/merge versmain— garde-fou (b) :mainn’est JAMAIS dans un état non vérifié ; une régression de dérive de toolchain est bloquée au merge, pas seulement signalée ;- planifié hebdomadaire (
schedule: cron '0 4 * * 1') — garde-fou (c) : détecte une dérive d’environnement runner sans activité de PR.
- Sinon (PR « couche 1 pure ») :
fmt,clippyetcargo test --workspaces’exécutent toujours (la couche 0 est bâtie + ses tests unitaires/doctests exécutés, juste pas re-mesurée) ; les étapes de couverture et leur mécanique de déterminisme (purge.profraw,sudo -n, restauration de propriété detarget/) sont sautées. 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é.
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é detarget/. Modèle de confiance : dépôt privé / mono-mainteneur (cf. ADR-031).
--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 appliqué en shell sur le %
TOTALdecargo +nightly llvm-cov --workspace --lib --branch --summary-only(pas de--fail-under-branchesnatif).
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.
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.