Aller au contenu
English

Empaqueter les dépendances du modèle

  • Leçon 2
  • 40 min
  • Module 1 : Définir le contrat d'inférence

Ce que vous saurez faire

Placez tout ce qui permet d’exécuter et de vérifier le modèle dans un dossier au numéro de version immuable.

Avant de commencerCe qu’il faut savoir et avoir sous la mainEnviron 40 minutes

Ce que vous devez savoir

Ce qu’il vous faut sur votre ordinateur

  • ~/projects/escalation-service avec .venv, requirements.txt et escalation/contract.py, provenant de Entrées et sorties des prédictions : écrire le contrat.
  • Le dossier ~/projects/ticket-model du cours d’apprentissage automatique, à côté, avec model/ (créé par python train.py) et data/ (valid.csv et extra_features.csv). Si vous avez plutôt téléchargé C17/start, il contient les deux dossiers, mais pas de .venv. Pour le premier exemple, utilisez le .venv de escalation-service.
  • Si votre projet manque ou ne fonctionne plus, vous pouvez copier le module terminé, end-of-m01, et suivre la lecture. Utiliser un instantané présente les étapes.

Testé avec Python 3.14.6 sur macOS, avec une puce Apple. Les étapes pour Windows et Linux ne sont pas testées.

Une bibliothèque à assembler arrive dans une seule boîte. Elle contient les planches, un sac de vis, les instructions et une liste pour vérifier les pièces. Un numéro de modèle figure sur la boîte. Si les vis étaient dans une autre boîte, les planches seraient inutiles. Quand l’usine change le modèle, elle imprime un nouveau numéro. Elle ne met jamais des planches différentes dans une boîte portant l’ancien numéro.

Le cours d’apprentissage automatique comparait un artefact de modèle à une recette qui doit inclure la température du four (Empaqueter les artefacts). Un service a besoin de plus que le dossier model/ de ce cours. Il lui faut le contrat, les versions testées, des billets de soutien pour les vérifications et une version désignant le tout.

Tomás regarde le pipeline du cours d’apprentissage automatique : « Le vrai modèle, c’est la dernière étape, la régression logistique. Sauvegardez seulement ça : le fichier est cinq fois plus petit. » Il sauvegarde pipeline[-1] et calcule le score du billet d’exemple :

Python, in ~/projects/ticket-model
import joblib
import pandas as pd
pipeline = joblib.load("model/escalation_model.joblib")
model = pipeline[-1] # only the logistic regression
ticket = pd.DataFrame([dict(channel="email", team="payment", segment="home", region="west",
priority=1, order_value=89.5, word_count=112, customer_tenure_days=400,
prior_tickets_90d=2, created_hour=10, prior_escalations_90d=1)])
model.predict_proba(ticket)

La sortie (abrégée) :

UserWarning: X has feature names, but LogisticRegression was fitted without feature names
...
ValueError: could not convert string to float: 'email'

Le fichier est plus petit (1 087 octets au lieu de 5 354), mais le modèle seul est inutilisable. La régression logistique n’a jamais vu le texte email. Elle a appris à partir de 28 nombres produits par le prétraitement. Celui-ci crée une colonne par catégorie connue et met les nombres à l’échelle, avec leurs indicateurs de valeurs manquantes. Les médianes, les moyennes, les échelles et la liste des catégories se trouvent toutes dans les étapes de prétraitement. Sans elles, personne ne peut reproduire ces 28 nombres exactement comme pendant l’entraînement.

La première règle de cette leçon est donc celle du cours d’apprentissage automatique : sauvegardez tout le pipeline, prétraitement et modèle, dans un seul objet. Le cas d’échec ci-dessous montre ce qui arrive quand Tomás tente plutôt de reconstruire le prétraitement.

Un fichier créé pendant l’entraînement, puis utilisé par un autre programme, est un artefact, comme dans le cours d’apprentissage automatique. Pour servir le modèle, un artefact ne suffit pas. Le service a besoin de fichiers qui restent ensemble, sous une même version. Dans ce cours, cet ensemble est un paquet : un dossier par version du modèle. Il contient tout ce qui permet d’exécuter le modèle et tout ce qui permet de le vérifier.

Le dossier ticket-model/model/ du cours d'apprentissage automatique, contenant escalation_model.joblib, model_info.json et check_sample.csv, ainsi que data/valid.csv, sont fournis à python make_bundle.py --version 1.0.0. Cette commande crée le paquet versionné bundles/escalation-1.0.0/ avec cinq fichiers : model.joblib, le pipeline avec prétraitement et modèle; contract.json, les variables, leur ordre, les règles et le seuil; metadata.json, la version, les empreintes des données et les versions testées; parity_cases.csv, 14 billets et leurs scores attendus; et SHA256SUMS, l'empreinte SHA-256 de chaque fichier. Une nouvelle version, comme bundles/escalation-1.1.0/, est un nouveau dossier, et 1.0.0 ne change jamais. escalation-service lit le paquet avec load_bundle au démarrage. Une boîte séparée, MODEL_SHA256 8f233029aa4c..., représente l'empreinte globale conservée dans un endroit fiable hors du paquet, fixée dans le service.
Le dossier du modèle issu de l'entraînement devient un paquet versionné de cinq fichiers. Le service lit le paquet et le vérifie avec une empreinte globale provenant de l'extérieur du paquet.
Description textuelle du diagramme

À gauche se trouve le côté entraînement : le dossier ticket-model/model/ du cours d’apprentissage automatique. Il contient escalation_model.joblib, model_info.json et check_sample.csv. Le fichier data/valid.csv fournit les cas de parité. Une flèche mène à python make_bundle.py --version 1.0.0.

Au milieu se trouve le paquet versionné, bundles/escalation-1.0.0/. Il contient cinq fichiers : model.joblib (le pipeline : prétraitement et modèle), contract.json (variables, ordre, règles, seuil), metadata.json (version, empreintes des données, versions testées), parity_cases.csv (14 billets et leurs scores attendus) et SHA256SUMS (l’empreinte SHA-256 de chaque fichier ci-dessus). En dessous, une boîte en pointillés indique qu’une nouvelle version, comme bundles/escalation-1.1.0/, est un nouveau dossier. Elle indique aussi que 1.0.0 ne change jamais.

À droite se trouve le côté service : escalation-service lit le paquet avec load_bundle au démarrage. Sous le service, une boîte en pointillés contient MODEL_SHA256, 8f233029aa4c.... C’est l’empreinte globale, soit l’empreinte de SHA256SUMS, conservée dans un endroit fiable hors du paquet. Une flèche en pointillés marquée « pinned » (fixée) mène de cette boîte au service.

Chaque fichier a un rôle :

  • model.joblib : le pipeline ajusté, avec les mêmes octets que le fichier escalation_model.joblib du cours d’apprentissage automatique. Il calcule les scores des billets exactement comme pendant l’entraînement.
  • contract.json : le contrat de la leçon précédente, écrit pour les personnes et les programmes. Il contient les variables dans l’ordre, le schéma JSON de TicketFeatures et les catégories connues. Il contient aussi les règles pour les valeurs manquantes et le sens de la sortie, avec le seuil.
  • metadata.json : l’origine du modèle. Il consigne la version, la date d’entraînement et le nombre de lignes d’entraînement. Il contient aussi les empreintes des données d’entraînement, les résultats de validation et les versions des bibliothèques ayant créé le modèle.
  • parity_cases.csv : 14 billets fixes avec le score que le modèle a donné à chacun lors de la création du paquet. Toute copie du modèle, où qu’elle soit, doit donner les mêmes scores. Il joue le rôle de check_sample.csv du cours d’apprentissage automatique, la photo du gâteau terminé.
  • SHA256SUMS et l’empreinte globale arrivent dans la prochaine leçon : ils prouvent que personne n’a modifié les fichiers.

Omar demande : « Le seuil est dans contract.json. Pourquoi pas dans les paramètres du service? » Parce que le seuil appartient à ce modèle. Un modèle réentraîné donne d’autres scores, et 0,19 peut alors signaler bien plus de 20 % des billets. Le module 5 montre exactement cela avec un vrai modèle candidat. Le seuil accompagne donc le modèle, dans le même dossier.

parity_cases.csv contient quatre types de billets, car chacun peut causer un problème différent :

Type Nombre Ce qu’il vérifie
normal 6 De vrais billets de validation sans valeur manquante
manquant 3 order_value nul, un invité avec customer_tenure_days nul, puis les deux : les médianes du mécanisme d’imputation
extrême 3 Des valeurs aux limites du contrat : T-900004 a tous les risques au maximum et un score d’exactement 1,0
inconnu 2 Un canal jamais vu par le modèle (social), puis une région et une équipe inconnues

Le module 3 utilise les mêmes 14 cas pour prouver que le service en cours d’exécution donne les scores de l’entraînement.

Une fois que bundles/escalation-1.0.0/ existe, personne ne le modifie. Un modèle réentraîné, un nouveau seuil ou même une faute corrigée dans contract.json crée une nouvelle version dans un nouveau dossier.

Pourquoi cette règle stricte? D’autres éléments font référence à « 1.0.0 » : les paramètres du service, chaque réponse consignée ("model_version": "1.0.0") et le registre des mises en production. Si 1.0.0 pouvait changer, le même nom désignerait deux modèles. Un retour en arrière vers 1.0.0 ne ramènerait alors pas le modèle qui fonctionnait. make_bundle.py applique la règle : il s’arrête si le dossier existe. Le coût est faible : un dossier d’environ 12 ko par version, conservé tant qu’un service ou un registre peut en avoir besoin.

metadata.json consigne les versions de scikit-learn, numpy, pandas et joblib. Dans la prochaine leçon, le service refuse de charger le paquet avec d’autres versions. scikit-learn ne garantit un objet sauvegardé que pour la version qui l’a sauvegardé (Empaqueter les artefacts : quand les versions diffèrent dans le cours d’apprentissage automatique). La version de Python est aussi consignée (3.14.6), mais pas vérifiée. Le conteneur du module 4 utilise Python 3.14.8. Une version corrective de Python ne change pas la façon dont ces bibliothèques calculent.

Créez un fichier, escalation/bundle.py. Il définit les noms des fichiers du paquet et les bibliothèques dont les versions doivent correspondre. La prochaine leçon ajoute le code qui charge un paquet.

escalation/bundle.py
"""The model bundle: everything the model needs to run, in one folder.
model.joblib the fitted pipeline (preprocessing + model)
contract.json the features, their order and rules, and the threshold
metadata.json the version, the training data and the tested versions
parity_cases.csv fixed tickets and the scores they got in training
"""
FILES = ["model.joblib", "contract.json", "metadata.json", "parity_cases.csv"]
PACKAGES = ["scikit-learn", "numpy", "pandas", "joblib"]

make_bundle.py va dans le dossier du projet, à côté de requirements.txt. La première partie choisit les cas de parité :

make_bundle.py
"""Make a model bundle from the C05 project's model/ folder.
python make_bundle.py ../ticket-model --version 1.0.0
Reads ../ticket-model/model/ (escalation_model.joblib, model_info.json) and
../ticket-model/data/ (for the parity cases), and writes
bundles/escalation-<version>/ with the model, its contract, its metadata and the
parity cases.
Run it only on a model that you trained yourself, or that comes from a source
you trust: it loads the joblib file, and loading can run code.
"""
import argparse
import json
import shutil
import sys
from importlib.metadata import version
from pathlib import Path
import joblib
import pandas as pd
from escalation.bundle import PACKAGES
from escalation.contract import FEATURES, TicketFeatures
# Fixed tickets that every copy of the model must score the same way.
# Normal: real validation tickets. Missing: the two numbers that may be null.
# Extreme: values at the edges of the contract. Unknown: a category the
# model never saw.
EDGE_CASES = [
("missing", "T-900001", dict(team="account", order_value=None)),
("missing", "T-900002", dict(segment="guest", customer_tenure_days=None, prior_tickets_90d=0, prior_escalations_90d=0)),
("missing", "T-900003", dict(order_value=None, customer_tenure_days=None)),
("extreme", "T-900004", dict(priority=1, order_value=5000, word_count=10000, prior_tickets_90d=500, prior_escalations_90d=500, created_hour=23)),
("extreme", "T-900005", dict(priority=3, order_value=0, word_count=0, customer_tenure_days=0, prior_tickets_90d=0, prior_escalations_90d=0, created_hour=0)),
("extreme", "T-900006", dict(customer_tenure_days=20000, created_hour=0)),
("unknown", "T-900007", dict(channel="social")),
("unknown", "T-900008", dict(region="overseas", team="installation")),
]
BASE = dict(channel="email", team="payment", segment="home", region="west", priority=2, order_value=120.0,
word_count=80, customer_tenure_days=500, prior_tickets_90d=1, created_hour=11, prior_escalations_90d=0)
def read(path):
"""Read a ticket CSV the way C05 does: only an empty cell is missing."""
return pd.read_csv(path, keep_default_na=False, na_values=[""])
def parity_cases(data: Path) -> pd.DataFrame:
valid = read(data / "valid.csv")
extra = read(data / "extra_features.csv")[["ticket_id", "prior_escalations_90d"]]
valid = valid.merge(extra, on="ticket_id", how="left", validate="one_to_one")
normal = valid.dropna(subset=["order_value", "customer_tenure_days"]).head(6)
rows = [{"case": "normal", "ticket_id": r.ticket_id, **{f: getattr(r, f) for f in FEATURES}}
for r in normal.itertuples()]
rows += [{"case": case, "ticket_id": tid, **{**BASE, **change}} for case, tid, change in EDGE_CASES]
cases = pd.DataFrame(rows, columns=["case", "ticket_id"] + FEATURES)
for name in ["priority", "word_count", "customer_tenure_days", "prior_tickets_90d", "created_hour",
"prior_escalations_90d"]:
cases[name] = cases[name].astype("Int64") # whole numbers, with an empty cell when missing
for row in cases[FEATURES].to_dict("records"): # every case must pass the contract
TicketFeatures(**{k: (None if pd.isna(v) else v) for k, v in row.items()})
return cases

Les cas limites modifient seulement quelques valeurs d’un billet BASE, pour que chaque cas teste une seule chose. La dernière boucle vérifie chaque cas avec le contrat : un cas de parité que le service rejetterait est inutile.

La deuxième partie, main(), va à la fin du même fichier. Elle écrit les quatre fichiers :

make_bundle.py (continued)
def main():
parser = argparse.ArgumentParser(description="Make a model bundle from a C05 project.")
parser.add_argument("project", type=Path, help="the C05 ticket-model folder")
parser.add_argument("--version", required=True, help="the model version, for example 1.0.0")
parser.add_argument("--model-dir", default="model", help="the folder in the project with the model")
args = parser.parse_args()
source = args.project / args.model_dir
info = json.loads((source / "model_info.json").read_text(encoding="utf-8"))
pipeline = joblib.load(source / "escalation_model.joblib")
if list(pipeline.feature_names_in_) != FEATURES:
sys.exit("Stopped: the model was trained on other features than this service expects.")
out = Path("bundles") / f"escalation-{args.version}"
if out.exists():
sys.exit(f"Stopped: {out} exists. A version is never changed: choose a new version.")
out.mkdir(parents=True)
shutil.copyfile(source / "escalation_model.joblib", out / "model.joblib")
cases = parity_cases(args.project / "data")
cases["expected_score"] = pipeline.predict_proba(cases[FEATURES])[:, 1]
cases.to_csv(out / "parity_cases.csv", index=False)
contract = {
"model": "escalation",
"model_version": args.version,
"unit": "one support ticket, scored when it is created",
"features": FEATURES,
"schema": TicketFeatures.model_json_schema(),
"known_categories": info["features"]["categories"],
"missing_values": {
"order_value": "null when the ticket has no order; the pipeline fills the training median",
"customer_tenure_days": "null for a guest; the pipeline fills the training median",
"other features": "never null: the request is rejected",
},
"unknown_categories": "allowed; scored as none of the known values; reported as a warning",
"output": {
"score": "0 to 1; a higher score means a higher risk of escalation; it ranks tickets",
"flag": "true if score >= threshold: send the ticket to the senior team",
"threshold": info["threshold"],
},
}
(out / "contract.json").write_text(json.dumps(contract, indent=2) + "\n", encoding="utf-8")
metadata = {
"model_version": args.version,
"description": info["model"],
"trained_at": info["trained_at"],
"train_rows": info["train_rows"],
"data_sha256": info["data_sha256"],
"evaluation": {"on": info.get("evaluated_on", "valid (May 2026)"), **info["valid"]},
"review_capacity": info["review_capacity"],
"python": info["versions"]["python"],
"versions": {p: version(p) for p in PACKAGES},
}
trained = {"scikit-learn": info["versions"]["scikit-learn"], "numpy": info["versions"]["numpy"],
"pandas": info["versions"]["pandas"], "joblib": info["versions"]["joblib"]}
if trained != metadata["versions"]:
sys.exit(f"Stopped: the model was trained with {trained}, but this environment has {metadata['versions']}.")
(out / "metadata.json").write_text(json.dumps(metadata, indent=2) + "\n", encoding="utf-8")
print(f"Made {out}: {len(cases)} parity cases, threshold {info['threshold']}.")
if __name__ == "__main__":
main()

Trois vérifications arrêtent le script avant qu’il écrive un paquet incorrect. Les variables du pipeline doivent correspondre à FEATURES. Le dossier ne doit pas exister. Et les versions des bibliothèques installées doivent correspondre à celles qui ont entraîné le modèle. Les scores attendus sont calculés ici, lors de la création du paquet, avec le pipeline inclus dans le paquet.

  1. Dans ~/projects/escalation-service, avec .venv actif, créez la version 1.0.0 :

    Terminal
    python make_bundle.py ../ticket-model --version 1.0.0

    La sortie :

    Made bundles/escalation-1.0.0: 14 parity cases, threshold 0.19.
  2. Listez les fichiers.

    Fenêtre de terminal
    ls -l bundles/escalation-1.0.0

    La sortie (macOS, abrégée) :

    -rw-r--r-- 1 ... 4270 Oct 8 10:35 contract.json
    -rw-r--r-- 1 ... 879 Oct 8 10:35 metadata.json
    -rw-r--r-- 1 ... 5354 Oct 8 10:35 model.joblib
    -rw-r--r-- 1 ... 1317 Oct 8 10:35 parity_cases.csv

    model.joblib contient 5 354 octets, comme le fichier du cours d’apprentissage automatique. Le paquet entier fait environ 12 ko.

  3. Exécutez la même commande à nouveau pour voir la règle de version :

    Terminal
    python make_bundle.py ../ticket-model --version 1.0.0

    La sortie :

    Stopped: bundles/escalation-1.0.0 exists. A version is never changed: choose a new version.

    Le code de sortie est 1, et rien n’a changé dans le dossier.

Ouvrez bundles/escalation-1.0.0/metadata.json. Le vôtre contient votre propre trained_at; le reste correspond :

bundles/escalation-1.0.0/metadata.json (shortened)
{
"model_version": "1.0.0",
"description": "Larkfield ticket escalation: logistic regression in a scikit-learn pipeline",
"trained_at": "2026-10-08 13:53:54 UTC",
"train_rows": 16939,
"data_sha256": {
"train.csv": "63b8732a28e891189cc28a9bcd505a093a9c4c51c01f3d0e1fba6d4959242d53",
...
},
"evaluation": {
"on": "valid (May 2026)",
...
"flag_rate": 0.193,
"precision": 0.379,
"recall": 0.47
},
"review_capacity": 0.2,
"python": "3.14.6",
"versions": {
"scikit-learn": "1.9.1",
"numpy": "2.5.3",
"pandas": "3.0.6",
"joblib": "1.6.0"
}
}

Une personne qui trouve ce dossier dans un an peut répondre à quatre questions. Quel est ce modèle? Quelles données l’ont entraîné? Quels étaient ses résultats? Quelles bibliothèques permettent de l’exécuter? Sans le dossier, personne ne pourrait y répondre.

À vous de jouer : répondre avec le paquet seulement

Section intitulée « À vous de jouer : répondre avec le paquet seulement »

Répondez à trois questions avec les fichiers de bundles/escalation-1.0.0/ seulement, sans utiliser le projet d’entraînement :

  1. Quel seuil le modèle 1.0.0 utilise-t-il?
  2. Quel cas de parité a le score attendu le plus élevé, et quel est ce score?
  3. Quelles valeurs de channel le modèle connaît-il?
Afficher la réponse

Une façon de faire, dans python :

Python, in ~/projects/escalation-service
import json
import pandas as pd
contract = json.load(open("bundles/escalation-1.0.0/contract.json"))
cases = pd.read_csv("bundles/escalation-1.0.0/parity_cases.csv")
print(contract["output"]["threshold"])
print(cases.loc[cases["expected_score"].idxmax(), ["case", "ticket_id", "expected_score"]].tolist())
print(contract["known_categories"]["channel"])

La sortie :

0.19
['extreme', 'T-900004', np.float64(1.0)]
['chat', 'email', 'phone', 'unknown', 'web_form']

np.float64(1.0) est la façon dont numpy affiche le nombre 1,0. La valeur unknown (inconnu) est une vraie catégorie dans les données de Larkfield : le canal n’a pas été consigné. Ce n’est pas une valeur manquante. social n’est pas dans la liste, donc cette valeur déclenche un avertissement.

Erreur courante

Reconstruire le prétraitement au moment du calcul des scores

Symptôme : Tomás garde seulement la régression logistique et reconstruit le prétraitement avec les mêmes paramètres. Il l’ajuste sur les billets dont il doit calculer les scores. Sur tout le mois de juillet (1 400 billets), le calcul échoue :

ValueError: X has 29 features, but LogisticRegression is expecting 28 features as input.

Il retire les 91 billets du nouveau canal social et réessaie. Cette fois, le calcul fonctionne sans erreur, mais en comparaison avec le vrai pipeline :

prep refitted on July without social (1309 rows): largest difference 0.068, median 0.007; flagged 0.177 vs 0.199; flag changed for 29 tickets

Cause : le prétraitement reconstruit a appris les médianes, les moyennes et les catégories de juillet, pas celles de l’entraînement. Une nouvelle catégorie ajoute une colonne, donc les dimensions ne correspondent pas. Sans nouvelle catégorie, les dimensions correspondent, mais chaque nombre change un peu : 29 billets ont changé de signalement, sans avertissement.

Solution : ne reconstruisez jamais ce que fit a appris. Empaquetez tout le pipeline ajusté et vérifiez chaque copie avec les cas de parité. Une différence de 0,068 dépasse immédiatement une tolérance de 1e-9.

Pour un vrai service d’IA : l’échec visible est le cas chanceux. L’échec silencieux change les décisions pour de vrais clients. C’est pourquoi un paquet contient ensemble le pipeline et les cas de parité.

Vérifiez votre compréhension

Tomás dit : la régression logistique est le modèle, donc model.joblib peut contenir seulement ça, et le service peut préparer les données. Donnez deux raisons pour lesquelles le paquet contient tout le pipeline.

Afficher la réponse

D’abord, le modèle a appris à partir de 28 nombres produits par le prétraitement ajusté. Ce sont des colonnes de codage un à un, des nombres imputés et mis à l’échelle, et des indicateurs de valeurs manquantes. Seul, il ne peut pas lire un billet : could not convert string to float: 'email' (impossible de convertir la chaîne « email » en nombre à virgule flottante). Ensuite, un prétraitement reconstruit au moment du calcul apprend d’autres médianes, échelles et catégories. Soit il échoue (29 variables au lieu de 28), soit il change les scores silencieusement. En juillet, 29 signalements ont changé sans erreur.

Vérifiez votre compréhension

Priya trouve une faute dans la description de contract.json du paquet 1.0.0, qui est en production. Devrait-elle corriger le fichier? Que devrait-elle faire?

Afficher la réponse

Non. Une version de paquet ne change jamais. Les paramètres, les réponses consignées et le registre des mises en production font tous référence à « 1.0.0 ». Un retour en arrière doit ramener exactement ce paquet. Elle crée une nouvelle version, par exemple 1.0.1, dans un nouveau dossier, et la met en production comme toute autre version.

Vérifiez votre compréhension

Pourquoi le seuil de 0,19 accompagne-t-il le paquet au lieu de figurer dans les paramètres du service?

Afficher la réponse

Le seuil a été choisi pour les scores de ce modèle, afin de signaler environ 20 % des billets. Un modèle réentraîné peut donner des scores plus élevés ou plus faibles. L’ancien seuil peut alors signaler beaucoup plus ou beaucoup moins de billets. Le seuil appartient donc à la version du modèle, et chaque nouvelle version apporte le sien.

Récapitulatif

  • Un modèle sans son prétraitement ajusté ne peut pas calculer le score d’un billet. Reconstruire le prétraitement échoue ou change les scores silencieusement.
  • Un paquet est un dossier par version du modèle. Il contient le pipeline, le contrat avec le seuil, les métadonnées avec les versions testées et 14 cas de parité.
  • Une version ne change jamais. Une correction crée une nouvelle version dans un nouveau dossier, et make_bundle.py refuse d’écraser le dossier existant.
  • bundles/escalation-1.0.0/ existe maintenant, avec quatre fichiers.

Ensuite, vous prouvez que personne n’a modifié le paquet. Vous ajoutez une empreinte par fichier et une empreinte globale pour tout le paquet. Un chargeur vérifie les deux avant d’exécuter quoi que ce soit.

Leçon suivante : Confiance et intégrité des artefacts