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 mainTests + 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.ymlpull_request, push sur mainGé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) :

RunnerMachineArcheLabelsRôle
raspi-srv-2Raspberry Pi 4 (Ubuntu)aarch64self-hosted, Linux, ARM64, air-aarch64Seul runner CI (ARM)
speedyMac mini Intel (Ubuntu)x86_64self-hosted, Linux, X64, air-x86_64Enregistré, 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 :

  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 lignes : cargo llvm-cov --workspace --fail-under-lines 96.
  5. Couverture branches : cargo +nightly llvm-cov --workspace --lib --branch --summary-only, puis extraction du % de la ligne TOTAL et application du plancher (78) en shell — cargo-llvm-cov n’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-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.

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 un booléen couche0 via git diff --name-only contre 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 :
    1. couche0 == true (PR touchant la couche 0 / toolchain / CI / politique) ;
    2. push/merge vers main — garde-fou (b) : main n’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 ;
    3. 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, clippy et cargo test --workspace s’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é de target/) 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-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é de target/. Modèle de confiance : dépôt privé / mono-mainteneur (cf. ADR-031).

--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 78 appliqué en shell sur le % TOTAL de cargo +nightly llvm-cov --workspace --lib --branch --summary-only (pas de --fail-under-branches natif).

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 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.