CI/CD for ML with DVC, MLflow and GitHub Actions
Build CI/CD for ML: DVC-versioned data, a trusted metric gate on every pull request, approved MLflow promotion and a tested rollback.
Blueprint Walkthrough
Architecture Overview & Problem Statement
This project builds a CI/CD pipeline for a machine learning model with DVC, MLflow and GitHub Actions. Every pull request retrains the model, and a separate job that never sees the pull request's files judges it with main's code, rules and data: if it is worse than the current champion, the merge is blocked. Every merge registers an exact model version, scores the test set once, and waits for a named reviewer before the champion alias moves. A rollback moves champion back to an earlier exact version through the same approval.
You finish with four pieces of evidence: a blocked pull request, a merged pull request that became a registered version, an approved promotion with the reviewer recorded, and a rollback. Plus one reproducibility proof: the same commit and data version rebuilt the same model file and metrics on the same machine.
The problem
The acme-fraud team at acme-shop retrains its fraud model whenever someone changes a parameter. Last quarter a "small tuning PR" dropped validation PR-AUC by a third and went live because nobody compared it with the model already serving. When the team tried to go back, nobody knew which data and code had produced the previous model. This project fixes all three: gate on metrics before merge, promote exact versions with a recorded approval, and keep enough lineage to rebuild or roll back any version.
How a pull request is judged
The pull request's code runs in one job. A second job checks out only main, downloads nothing from the pull request except the trained model file, and decides.
PR code + PR params] T -->|model file only| J[Job 2: judge
main's code, rules, data] J -->|fail| X([Blocked]) J -->|pass| M([Merge allowed]) classDef person fill:#DBEAFE,color:#1E293B,stroke:#326CE5 classDef untrusted fill:#FFEDD5,color:#1E293B,stroke:#F97316 classDef trusted fill:#326CE5,color:#FFFFFF,stroke:#326CE5 classDef bad fill:#EF4444,color:#FFFFFF,stroke:#EF4444 classDef good fill:#10B981,color:#FFFFFF,stroke:#10B981 class PR person class T untrusted class J trusted class X bad class M good
How a merge becomes the champion
gate passed Registered --> Tested: test set once Tested --> Champion: reviewer approves Champion --> FormerChampion: next promotion FormerChampion --> Champion: approved rollback
Where state lives
A free public GitHub repository has no server to host a model registry, so this project keeps state on two extra branches, each checked out as a folder next to the code:
| Branch | Folder | Holds |
|---|---|---|
main |
project root | code, params, dvc.lock, data pointer |
dvc-store |
.dvc-store/ |
DVC remote: content-addressed data files |
registry-state |
.registry/ |
MLflow SQLite DB, model artifacts, test reports, champion baseline, audit log |
Every job starts a local MLflow server on top of .registry/, and release jobs commit it back. This is a portfolio workaround, not a production registry: the SQLite file and model artifacts grow with every version, Git keeps every old copy forever, and only one writer can work at a time. In a company you point MLFLOW_TRACKING_URI at a shared MLflow server and the DVC remote at S3, and give only main release jobs credentials through OIDC (covered in CI/CD and Continuous Training for ML). The scripts do not change.
How the data is split
| Split | Share | Used for |
|---|---|---|
| fit | 51.2% | training |
| calibration | 12.8% | picking the decision threshold |
| validation | 16% | every gate decision (96 fraud rows) |
| test | 20% | one final report per version (120 fraud rows) |
The threshold is chosen on calibration and checked on validation, so the recall gate is an independent check, not a number the model was tuned to hit.
Repository layout
acme-fraud-cicd/├── params.yaml # model knobs, split, threshold policy, gate rules├── requirements.txt # pinned; also a DVC dependency of training├── dvc.yaml dvc.lock # the train stage and its exact inputs├── data/payments.csv.dvc # pointer to the data version (md5)├── src/acme_fraud/ # data.py (splits + lineage helpers), train.py├── scripts/│ ├── make_data.py # create the dataset once│ ├── registry.sh # start/stop MLflow on .registry/ (waits for shutdown)│ ├── save_registry.sh # commit + push .registry/, fail loudly│ ├── registry_util.py # champion and version lookup│ ├── evaluate_model.py # evaluator exit 0 / 4│ ├── gate.py # quality gate exit 0 / 1 / 4│ ├── register_candidate.py # exact-version register exit 0 / 1 / 4│ ├── test_once.py # test once, recoverable exit 0 / 1 / 3 / 4│ ├── promote_ci.py # promote / roll back exit 0 / 1 / 3 / 4 / 5│ ├── repro_check.py # rebuild and compare exit 0 / 1 / 4│ └── whoserves.py # what @champion loads now├── tests/ # test_gate.py, test_lifecycle.py└── .github/workflows/ # pr-gate.yml, release.ymlPrerequisites (by module title)
- Deploy an ML Model with FastAPI, Docker and AWS (the previous project: same dataset and model family).
- Data Versioning with DVC, MLflow Experiment Tracking and Model Registry, CI/CD and Continuous Training for ML, and GitHub Actions.
- Tools: Python 3.12, Git 2.40 or newer, the GitHub CLI
gh, a free GitHub account,curl.
This project reuses the script names and exit codes from CI/CD and Continuous Training for ML. The versions below are the ones this project was run with, extended with checks found in review. If yours differ, keep the behaviour described here.
Versions used
| Tool | Version | Checked |
|---|---|---|
| DVC / MLflow | 3.67.1 / 3.18.0 | PyPI, 5 Nov 2026 |
| scikit-learn / pandas / numpy | 1.9.1 / 3.1.1 / 2.5.4 | PyPI, 5 Nov 2026 |
| checkout / setup-python / upload / download-artifact | v7.0.1 / v7.1.0 / v7.0.2 / v7.0.0, pinned by SHA | GitHub tags, 5 Nov 2026 |
TipWhat was run for this guide: every script, the 13 tests, the full local flow (first champion, blocked PR, merged PR, promotion, rollback), four break-it exercises and the reproducibility check from a fresh clone, in one clean run on Python 3.12 in a Linux sandbox. Outputs marked "real run" come from that run. The judge job was simulated locally by evaluating the PR's model file from a
maincheckout. The two GitHub Actions workflows, the reviewer lookup through the GitHub API, branch protection and environment approval were not run (no GitHub access from the sandbox). The workflows were checked for YAML validity only, which is not the same as running them. Run them once yourself before calling the project done.
Milestone 1: Set Up the Repo, the Data and the DVC Pipeline
This milestone creates the repository, versions the dataset with DVC, and defines one pipeline stage so dvc repro retrains only when code, data, parameters or pinned libraries change.
Why the data needs a version too
A model is a function of code, parameters, libraries and data. Git versions the first three. DVC versions the data by storing the file in a remote and committing a small pointer (payments.csv.dvc, containing the file's md5) to Git. One commit then pins all four.
Step 1: Create the project
mkdir acme-fraud-cicd && cd acme-fraud-cicdgit init -b mainpython3.12 -m venv .venv && source .venv/bin/activatemkdir -p src/acme_fraud scripts tests .github/workflowstouch src/acme_fraud/__init__.pyexport PYTHONPATH=src MLFLOW_DISABLE_AGENT_HINT=1 MLFLOW_DISABLE_TELEMETRY=truerequirements.txt:
scikit-learn==1.9.1pandas==3.1.1numpy==2.5.4joblib==1.6.0pyyaml==6.0.3mlflow==3.18.0dvc==3.67.1pytest==9.2.0pip install -r requirements.txt.gitignore:
.venv/__pycache__/.pytest_cache/.registry/.dvc-store/.mlflow-server.*metrics/candidate/trusted//modelStep 2: Add the parameters, data and training code
params.yaml. The gate block is the rulebook; the judge job reads it from main, so a pull request cannot loosen it. The floors were written before the first run of this design.
seed: 42data: path: data/payments.csv rows: 20000 fraud_rate: 0.03split: test_size: 0.20 # 20 percent: looked at once per registered version val_size: 0.20 # 16 percent overall: every gate decision uses this calib_size: 0.20 # 12.8 percent overall: only for picking the thresholdmodel: max_iter: 200 learning_rate: 0.05 max_leaf_nodes: 31threshold: calib_recall_target: 0.80 # threshold = highest one reaching this on CALIBRATIONgate: # Written before the first run of this design. Illustrative, not business targets. min_val_pr_auc: 0.18 min_val_recall: 0.65 # checked on VALIDATION, which never saw the threshold choice max_drop_vs_champion: 0.01src/acme_fraud/data.py (same generator as the previous project, plus the four-way split and lineage helpers):
"""Payments data and the fixed evaluation layout every script shares."""import hashlibimport jsonfrom pathlib import Path import numpy as npimport pandas as pdimport yamlfrom sklearn.model_selection import train_test_split FEATURES = ["amount", "hour", "merchant_category", "country_mismatch", "card_age_days", "txns_last_24h"]CATEGORIES = ["grocery", "electronics", "travel", "fuel", "fashion", "gaming"] def load_params(path: str = "params.yaml") -> dict: with open(path) as f: return yaml.safe_load(f) def make_payments(rows: int, fraud_rate: float, seed: int) -> pd.DataFrame: """Synthetic card payments with a weak, noisy fraud signal (same as the previous project).""" rng = np.random.default_rng(seed) df = pd.DataFrame({ "txn_id": [f"txn_{i:06d}" for i in range(rows)], "amount": np.round(rng.lognormal(mean=3.5, sigma=1.0, size=rows), 2), "hour": rng.integers(0, 24, size=rows), "merchant_category": rng.choice(CATEGORIES, size=rows), "country_mismatch": rng.binomial(1, 0.08, size=rows), "card_age_days": rng.integers(1, 3650, size=rows), "txns_last_24h": rng.poisson(2.0, size=rows), }) score = ( 0.9 * np.log1p(df["amount"]) / 5 + 1.2 * df["country_mismatch"] + 0.6 * df["merchant_category"].isin(["electronics", "gaming"]) + 0.5 * ((df["hour"] <= 4) | (df["hour"] >= 23)) + 0.25 * np.minimum(df["txns_last_24h"], 8) / 2 - 0.6 * (df["card_age_days"] > 365) + rng.normal(0, 1.0, size=rows) ) df["is_fraud"] = (score >= np.quantile(score, 1 - fraud_rate)).astype(int) return df def load_splits(params: dict): """train_fit / calib / val / test = 51.2 / 12.8 / 16 / 20 percent, stratified and seeded.""" df = pd.read_csv(params["data"]["path"]) s, seed = params["split"], params["seed"] rest, test = train_test_split(df, test_size=s["test_size"], stratify=df["is_fraud"], random_state=seed) train, val = train_test_split(rest, test_size=s["val_size"], stratify=rest["is_fraud"], random_state=seed) fit, calib = train_test_split(train, test_size=s["calib_size"], stratify=train["is_fraud"], random_state=seed) return fit, calib, val, test # ---- lineage helpers: what exactly was this model judged on? ----def file_md5(path: str) -> str: return hashlib.md5(Path(path).read_bytes()).hexdigest() def dvc_md5(dvc_file: str = "data/payments.csv.dvc") -> str: """The data version Git points at (the md5 DVC wrote into the .dvc file).""" return yaml.safe_load(Path(dvc_file).read_text())["outs"][0]["md5"] def eval_config_sha(params: dict) -> str: """Fingerprint of everything that decides WHICH rows are validation and test.""" cfg = {"seed": params["seed"], "split": params["split"], "data": params["data"]} return hashlib.sha256(json.dumps(cfg, sort_keys=True).encode()).hexdigest()[:16]scripts/make_data.py:
"""Create data/payments.csv once. After this, DVC owns the file's versions."""from pathlib import Path from acme_fraud.data import load_params, make_payments params = load_params()path = Path(params["data"]["path"])path.parent.mkdir(parents=True, exist_ok=True)make_payments(params["data"]["rows"], params["data"]["fraud_rate"], params["seed"]).to_csv(path, index=False)print(f"wrote {path}")src/acme_fraud/train.py:
"""Train the model; pick its threshold on the CALIBRATION split. Run by `dvc repro`. This script does not decide whether the model ships. The judge job runs main'sevaluator and gate on the model this produces."""import hashlibimport jsonimport osfrom pathlib import Path import joblibimport numpy as npfrom sklearn.compose import ColumnTransformerfrom sklearn.ensemble import HistGradientBoostingClassifierfrom sklearn.metrics import recall_scorefrom sklearn.pipeline import Pipelinefrom sklearn.preprocessing import OneHotEncoder from acme_fraud.data import FEATURES, eval_config_sha, file_md5, load_params, load_splits MODEL_DIR = Path("model") def build_model(params: dict) -> Pipeline: pre = ColumnTransformer( [("cat", OneHotEncoder(handle_unknown="ignore"), ["merchant_category"])], remainder="passthrough", ) clf = HistGradientBoostingClassifier( max_iter=params["model"]["max_iter"], learning_rate=params["model"]["learning_rate"], max_leaf_nodes=params["model"]["max_leaf_nodes"], class_weight="balanced", random_state=params["seed"], ) return Pipeline([("pre", pre), ("clf", clf)]) def pick_threshold(y_true, proba, recall_target: float) -> float: """Highest threshold reaching the recall target; 0.0 is the last try.""" for t in np.round(np.arange(0.99, -0.001, -0.01), 2): if recall_score(y_true, proba >= t) >= recall_target: return float(t) raise ValueError(f"no threshold reaches recall {recall_target}") def main() -> None: params = load_params() fit, calib, _val, _test = load_splits(params) # val and test are never touched here model = build_model(params).fit(fit[FEATURES], fit["is_fraud"]) calib_proba = model.predict_proba(calib[FEATURES])[:, 1] threshold = pick_threshold(calib["is_fraud"], calib_proba, params["threshold"]["calib_recall_target"]) MODEL_DIR.mkdir(exist_ok=True) joblib.dump(model, MODEL_DIR / "model.joblib.tmp") os.replace(MODEL_DIR / "model.joblib.tmp", MODEL_DIR / "model.joblib") meta = { "model_sha256": hashlib.sha256((MODEL_DIR / "model.joblib").read_bytes()).hexdigest(), "threshold": threshold, "trained_on_data_md5": file_md5(params["data"]["path"]), "eval_config_sha": eval_config_sha(params), "features": FEATURES, } (MODEL_DIR / "metadata.json.tmp").write_text(json.dumps(meta, indent=2)) os.replace(MODEL_DIR / "metadata.json.tmp", MODEL_DIR / "metadata.json") print(f"trained model sha256={meta['model_sha256'][:12]} threshold={threshold}") if __name__ == "__main__": main()Step 3: Define the pipeline
dvc.yaml (note requirements.txt as a dependency: a library upgrade alone must retrain):
stages: train: cmd: python -m acme_fraud.train deps: - src/acme_fraud - data/payments.csv - requirements.txt # a library upgrade must retrain too params: - seed - data - split - model - threshold outs: - modelStep 4: Commit the first version
git add -A && git commit -m "scaffold"dvc initpython scripts/make_data.pydvc add data/payments.csvdvc reprogit add -A && git commit -m "v1 baseline pipeline"Expected output of dvc repro (real run, last line):
trained model sha256=c609f5f91f7b threshold=0.31Acceptance check
-
cat data/payments.csv.dvcshows anmd5, anddvc.locklists the same md5 under thetrainstage. - Running
dvc reproagain printsStage 'train' didn't change, skipping.
Common MistakeCommitting
data/payments.csvto Git as well.dvc addwrites it intodata/.gitignore; if you forced it in before, rungit rm --cached data/payments.csv.
Milestone 2: Put the Data Store and the Registry on Branches
This milestone creates the dvc-store and registry-state branches, checks each out as a folder with git worktree, points DVC at one and a local MLflow server at the other, and adds a save script that never hides a failed push.
Step 1: Create the two state branches
for b in dvc-store registry-state; do git switch --orphan "$b" && git commit --allow-empty -m "init $b" && git switch maindonegit worktree add .dvc-store dvc-storegit worktree add .registry registry-stateStep 2: Point DVC at the data store and push the data
dvc remote add -d store .dvc-store # relative to where you run itcat .dvc/configdvc pushcd .dvc-store && git add -A && git commit -m "data v1" && cd ..git add .dvc/config && git commit -m "dvc remote: dvc-store branch"Expected output (real run; the 4 files are the dataset plus the cached model outputs):
[core] remote = store['remote "store"'] url = ../.dvc-store4 files pushedCommon MistakeTyping
dvc remote add -d store ../.dvc-storebecause the config file lives in.dvc/. DVC converts the path you type (relative to your shell) into one relative to the config file, so../.dvc-storebecomes../../.dvc-store, outside your repo, anddvc pushsilently fills the wrong folder.
Step 3: Start, stop and save the registry
scripts/registry.sh (stop waits until the server and its workers have exited, so nothing is still writing the database):
#!/usr/bin/env bash# Start or stop a local MLflow server whose state lives in .registry/# (.registry/ is a git worktree of the registry-state branch).# Usage: scripts/registry.sh start|stopset -euo pipefailcase "${1:-}" in start) mkdir -p .registry/artifacts mlflow server --backend-store-uri sqlite:///.registry/mlflow.db \ --artifacts-destination ./.registry/artifacts \ --host 127.0.0.1 --port 5000 > .mlflow-server.log 2>&1 & echo $! > .mlflow-server.pid for _ in $(seq 1 60); do curl -sf http://127.0.0.1:5000/health >/dev/null && { echo "mlflow up"; exit 0; }; sleep 1; done echo "mlflow did not start, see .mlflow-server.log"; exit 1 ;; stop) # Stop the server AND its workers, and wait until they are gone, so nothing is # still writing mlflow.db when we commit it. [ -f .mlflow-server.pid ] || { echo "mlflow not running"; exit 0; } PID=$(cat .mlflow-server.pid) pkill -TERM -P "$PID" 2>/dev/null || true kill -TERM "$PID" 2>/dev/null || true for _ in $(seq 1 30); do if ! kill -0 "$PID" 2>/dev/null && ! pgrep -P "$PID" >/dev/null 2>&1; then rm -f .mlflow-server.pid; echo "mlflow stopped"; exit 0 fi sleep 1 done echo "mlflow did not stop within 30 s; not safe to commit the registry"; exit 1 ;; *) echo "usage: $0 start|stop"; exit 4 ;;esacscripts/save_registry.sh ("nothing to commit" is the only failure it accepts; a failed commit or a rejected push fails the job):
#!/usr/bin/env bash# Stop MLflow cleanly, then commit and push .registry/. Fails loudly on any real error.# Usage: scripts/save_registry.sh "<commit message>"set -euo pipefailscripts/registry.sh stopcd .registrygit add -Aif git diff --cached --quiet; then echo "nothing to save"; exit 0; fi # the only harmless casegit -c user.name="${GIT_NAME:-release-bot}" -c user.email="${GIT_EMAIL:-release-bot@users.noreply.github.com}" \ commit -q -m "$1"if [ "${PUSH:-1}" = "1" ]; then # A rejected push means someone else moved the registry. Do not overwrite it: fail, rerun. git push origin HEAD:registry-state || { echo "ERROR: push rejected; registry moved. Rerun the job."; exit 1; }fiecho "registry saved: $(git log --oneline -1)"scripts/registry_util.py:
"""Small helpers shared by the release scripts."""import os from mlflow import MlflowClientfrom mlflow.exceptions import MlflowException MODEL_NAME = "acme-fraud"os.environ.setdefault("MLFLOW_TRACKING_URI", "http://127.0.0.1:5000")os.environ.setdefault("MLFLOW_ENABLE_ARTIFACTS_PROGRESS_BAR", "false") # quieter CI logs def client() -> MlflowClient: return MlflowClient() def current_champion(c: MlflowClient): """The version behind @champion, or None if no champion exists yet.""" try: return c.get_model_version_by_alias(MODEL_NAME, "champion") except MlflowException as err: # MLflow 3.18 reports a missing alias as INVALID_PARAMETER_VALUE if err.error_code in ("INVALID_PARAMETER_VALUE", "RESOURCE_DOES_NOT_EXIST"): return None raise def get_version(c: MlflowClient, version: str): try: return c.get_model_version(MODEL_NAME, str(version)) except MlflowException: return Nonechmod +x scripts/*.shscripts/registry.sh start # prints: mlflow upcurl -s localhost:5000/health # prints: OK--artifacts-destination makes the server store artifact paths as mlflow-artifacts:/..., relative to the server, instead of absolute paths of your laptop. That is what lets a CI runner with a different home folder read the same registry.
Acceptance check
-
git worktree listshows three entries: the project,.dvc-storeand.registry. -
scripts/registry.sh stopprintsmlflow stoppedand nomlflow serverprocess is left.
Milestone 3: Write the Evaluator and the Gate
This milestone writes the two scripts that decide whether a model may ship: an evaluator that recomputes validation metrics and checks the data on disk is the version Git points at, and a gate that applies fixed floors, requires the same evaluation data as the champion, and compares with the champion.
Where the trust boundary is
In CI the evaluator and gate run in the judge job, which checks out only main. Every input it reads, params.yaml, the data pointer, the data, the libraries and the scripts, comes from main. The only thing it takes from the pull request is the trained model file. The evaluator still takes --params and --dvc-file explicitly, so it never depends on whatever happens to be in the working directory.
What this does not protect against, said plainly:
- The judge unpickles the PR's model, and unpickling runs code. A deliberately malicious PR could tamper with the evaluation at load time. That is why the judge job has no secrets and a read-only token.
- The PR's training code has the data. It could train on validation rows and inflate its score. Code review before merge catches that; the release job then retrains from reviewed code on
main, and the test set is scored once after that.
Step 1: Write the evaluator
All metrics are stored at full precision. Rounding happens only when printing, so a PR-AUC of 0.17996 cannot round up to 0.18 and pass.
scripts/evaluate_model.py:
"""Evaluator: recompute validation metrics for a model file. In CI this runs in the JUDGE job, which checks out only main: main's code, main'sparams, main's data pointer and main's data. The PR's files never reach this job;only the PR's model file does (downloaded as an artifact).Exit codes: 0 = metrics written, 4 = bad input (missing file, hash or data mismatch)."""import argparseimport hashlibimport jsonimport subprocessimport sysfrom pathlib import Path import joblibfrom sklearn.metrics import average_precision_score, precision_score, recall_score from acme_fraud.data import FEATURES, dvc_md5, eval_config_sha, file_md5, load_params, load_splits def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--model-dir", default="model") ap.add_argument("--params", default="params.yaml") # explicit, never implied ap.add_argument("--dvc-file", default="data/payments.csv.dvc") ap.add_argument("--out", default="metrics/val.json") a = ap.parse_args() try: params = load_params(a.params) meta = json.loads((Path(a.model_dir) / "metadata.json").read_text()) blob = (Path(a.model_dir) / "model.joblib").read_bytes() pointer, actual = dvc_md5(a.dvc_file), file_md5(params["data"]["path"]) except (FileNotFoundError, KeyError) as err: print(f"ERROR: {err}"); return 4 if hashlib.sha256(blob).hexdigest() != meta["model_sha256"]: print("ERROR: model file does not match its metadata"); return 4 if actual != pointer: # the bytes on disk must be the data version Git points at print(f"ERROR: {params['data']['path']} does not match {a.dvc_file} (run: dvc checkout --force)"); return 4 _fit, _calib, val, _test = load_splits(params) model = joblib.load(Path(a.model_dir) / "model.joblib") # unpickling runs code: no secrets here proba = model.predict_proba(val[FEATURES])[:, 1] pred = proba >= meta["threshold"] metrics = { # full precision: decisions use these; round only when printing "val_pr_auc": float(average_precision_score(val["is_fraud"], proba)), "val_recall": float(recall_score(val["is_fraud"], pred)), "val_precision": float(precision_score(val["is_fraud"], pred, zero_division=0)), "threshold": meta["threshold"], "model_sha256": meta["model_sha256"], "data_md5": pointer, # data the model was JUDGED on "trained_on_data_md5": meta["trained_on_data_md5"], # data the model says it trained on "eval_config_sha": eval_config_sha(params), "git_sha": subprocess.run(["git", "rev-parse", "HEAD"], capture_output=True, text=True).stdout.strip(), "val_rows": int(len(val)), "val_positives": int(val["is_fraud"].sum()), } Path(a.out).parent.mkdir(parents=True, exist_ok=True) Path(a.out).write_text(json.dumps(metrics, indent=2)) print(f"val_pr_auc={metrics['val_pr_auc']:.4f} val_recall={metrics['val_recall']:.4f} " f"threshold={metrics['threshold']} positives={metrics['val_positives']}") return 0 if __name__ == "__main__": sys.exit(main())Step 2: Write the gate
The gate's checks, in order:
- Two floors from
params.yaml: validation PR-AUC and validation recall. - The model was trained on the same data version it is judged on. A pull request that changes the dataset fails here: comparing scores across datasets is apples to oranges, and a dataset change needs its own policy (both models re-scored on one fixed comparison set), which is outside this project.
- Same data md5 and same split configuration as the champion's baseline.
- Validation PR-AUC no more than 0.01 below the champion's. "Not worse", not "better": with 96 fraud rows in validation, small moves are noise.
scripts/gate.py:
"""Quality gate: fixed floors, same evaluation data as the champion, not worse than it. Exit codes: 0 = pass, 1 = fail, 4 = bad input."""import argparseimport jsonimport sysfrom pathlib import Path import yaml def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--candidate", default="metrics/val.json") ap.add_argument("--baseline", default=".registry/baseline/champion.json") ap.add_argument("--params", default="params.yaml") ap.add_argument("--out", default="metrics/gate.json") a = ap.parse_args() try: cand = json.loads(Path(a.candidate).read_text()) rules = yaml.safe_load(Path(a.params).read_text())["gate"] except (FileNotFoundError, KeyError) as err: print(f"ERROR: {err}"); return 4 base = json.loads(Path(a.baseline).read_text()) if Path(a.baseline).exists() else None checks = [ ("val_pr_auc >= floor", cand["val_pr_auc"] >= rules["min_val_pr_auc"], f"{cand['val_pr_auc']:.4f} vs {rules['min_val_pr_auc']}"), ("val_recall >= floor", cand["val_recall"] >= rules["min_val_recall"], f"{cand['val_recall']:.4f} vs {rules['min_val_recall']}"), ("trained on the evaluation data", cand["trained_on_data_md5"] == cand["data_md5"], "dataset changes need the data-change policy, not this gate" if cand["trained_on_data_md5"] != cand["data_md5"] else cand["data_md5"][:8]), ] if base is None: checks.append(("champion comparison", True, "no champion yet: first release still needs approval")) else: same = cand["data_md5"] == base["data_md5"] and cand["eval_config_sha"] == base["eval_config_sha"] checks.append(("same evaluation data as champion", same, f"data {cand['data_md5'][:8]} vs {base['data_md5'][:8]}, " f"config {cand['eval_config_sha'][:8]} vs {base['eval_config_sha'][:8]}")) limit = base["val_pr_auc"] - rules["max_drop_vs_champion"] checks.append(("not worse than champion", same and cand["val_pr_auc"] >= limit, f"{cand['val_pr_auc']:.4f} vs {limit:.4f} (champion v{base['version']} = {base['val_pr_auc']:.4f})")) passed = all(ok for _, ok, _ in checks) for name, ok, detail in checks: print(f"{'PASS' if ok else 'FAIL'} {name}: {detail}") print("GATE PASSED" if passed else "GATE FAILED") Path(a.out).parent.mkdir(parents=True, exist_ok=True) Path(a.out).write_text(json.dumps({"passed": passed, "model_sha256": cand["model_sha256"], "checks": [{"name": n, "ok": o, "detail": d} for n, o, d in checks]}, indent=2)) return 0 if passed else 1 if __name__ == "__main__": sys.exit(main())Step 3: Write the tests
tests/test_gate.py:
"""The gate decides what ships, so test its rules directly."""import jsonimport subprocessimport sys CAND = {"model_sha256": "x", "data_md5": "d1", "trained_on_data_md5": "d1", "eval_config_sha": "e1"}BASE = {"version": "1", "val_pr_auc": 0.30, "data_md5": "d1", "eval_config_sha": "e1"} def gate(tmp_path, cand: dict, base: dict | None) -> int: (tmp_path / "c.json").write_text(json.dumps({**CAND, **cand})) if base: (tmp_path / "b.json").write_text(json.dumps(base)) return subprocess.run([sys.executable, "scripts/gate.py", "--candidate", str(tmp_path / "c.json"), "--baseline", str(tmp_path / "b.json"), "--out", str(tmp_path / "g.json")], capture_output=True).returncode def test_first_model_only_needs_floors(tmp_path): assert gate(tmp_path, {"val_pr_auc": 0.25, "val_recall": 0.70}, None) == 0 def test_worse_than_champion_fails(tmp_path): assert gate(tmp_path, {"val_pr_auc": 0.28, "val_recall": 0.80}, BASE) == 1 def test_small_drop_within_tolerance_passes(tmp_path): assert gate(tmp_path, {"val_pr_auc": 0.295, "val_recall": 0.80}, BASE) == 0 def test_recall_below_floor_fails(tmp_path): assert gate(tmp_path, {"val_pr_auc": 0.40, "val_recall": 0.60}, None) == 1 def test_different_evaluation_data_fails(tmp_path): assert gate(tmp_path, {"val_pr_auc": 0.40, "val_recall": 0.80, "data_md5": "d2", "trained_on_data_md5": "d2"}, BASE) == 1 def test_trained_on_other_data_fails(tmp_path): assert gate(tmp_path, {"val_pr_auc": 0.40, "val_recall": 0.80, "trained_on_data_md5": "d9"}, None) == 1 def test_rounding_cannot_lift_a_score_over_the_floor(tmp_path): assert gate(tmp_path, {"val_pr_auc": 0.17996, "val_recall": 0.80}, None) == 1 # rounds to 0.1800tests/test_lifecycle.py (uses a throwaway SQLite registry, no server):
"""Release lifecycle rules: data integrity, promotion refusals, rollback, recovery. Uses a throwaway MLflow SQLite registry in a temp folder; no server needed."""import jsonimport osimport shutilimport subprocessimport sysfrom pathlib import Path import mlflowimport pytestfrom sklearn.linear_model import LogisticRegression REPO = Path(__file__).resolve().parents[1] @pytest.fixture()def registry(tmp_path): uri = f"sqlite:///{tmp_path}/mlflow.db" mlflow.set_tracking_uri(uri) exp = mlflow.create_experiment("t", artifact_location=(tmp_path / "art").as_uri()) model = LogisticRegression().fit([[0], [1]], [0, 1]) with mlflow.start_run(experiment_id=exp): info = mlflow.sklearn.log_model(model, name="model") # one artifact, three versions for v, tags in [(1, {"gate": "passed", "tested": "true"}), (2, {"gate": "passed", "tested": "true"}), (3, {"gate": "passed"})]: # v3 skipped its test run base = {"val_pr_auc": "0.3", "val_recall": "0.8", "threshold": "0.5", "data_md5": "d1", "eval_config_sha": "e1", "model_sha256": f"m{v}", "git_sha": f"g{v}"} mlflow.register_model(info.model_uri, "acme-fraud", tags={**base, **tags}) env = {**os.environ, "MLFLOW_TRACKING_URI": uri, "PYTHONPATH": str(REPO / "src")} return tmp_path, env def promote(reg, *args) -> subprocess.CompletedProcess: tmp, env = reg return subprocess.run([sys.executable, str(REPO / "scripts/promote_ci.py"), "--baseline-dir", str(tmp / "state/baseline"), "--approved-by", "alice", *args], capture_output=True, text=True, env=env) def test_untested_version_cannot_be_promoted(registry): assert promote(registry, "--version", "3", "--release-mode", "promote").returncode == 1 def test_promote_then_rollback_moves_alias_and_baseline(registry): tmp, _ = registry assert promote(registry, "--version", "1", "--release-mode", "promote").returncode == 0 assert promote(registry, "--version", "2", "--release-mode", "promote").returncode == 0 assert promote(registry, "--version", "1", "--release-mode", "rollback").returncode == 0 base = json.loads((tmp / "state/baseline/champion.json").read_text()) assert base["version"] == "1" and base["data_md5"] == "d1" and base["eval_config_sha"] == "e1" assert len((tmp / "state/audit.log").read_text().splitlines()) == 3 def test_rollback_needs_a_former_champion(registry): assert promote(registry, "--version", "1", "--release-mode", "promote").returncode == 0 assert promote(registry, "--version", "2", "--release-mode", "rollback").returncode == 1 def test_stale_approval_is_refused(registry): assert promote(registry, "--version", "1", "--release-mode", "promote").returncode == 0 r = promote(registry, "--version", "2", "--release-mode", "promote", "--expect-champion", "none") assert r.returncode == 1 and "Re-approve" in r.stdout def test_interrupted_change_blocks_until_recovered(registry): tmp, _ = registry assert promote(registry, "--version", "1", "--release-mode", "promote").returncode == 0 pending = {"op_id": "op-test", "mode": "promote", "from": "v1", "to": "2", "approved_by": "alice", "requested_by": "bob", "baseline": {"version": "2", "val_pr_auc": 0.3, "val_recall": 0.8, "threshold": 0.5, "data_md5": "d1", "eval_config_sha": "e1", "model_sha256": "m2", "git_sha": "g2"}} (tmp / "state/pending.json").write_text(json.dumps(pending)) # simulate a crash after step 1 assert promote(registry, "--version", "2", "--release-mode", "promote").returncode == 5 assert promote(registry, "--recover").returncode == 0 assert json.loads((tmp / "state/baseline/champion.json").read_text())["version"] == "2" assert not (tmp / "state/pending.json").exists() def test_evaluator_rejects_data_that_differs_from_its_pointer(tmp_path): shutil.copy(REPO / "data/payments.csv", tmp_path / "p.csv") with open(tmp_path / "p.csv", "a") as f: f.write("txn_999999,1.0,3,fuel,0,10,1,0\n") # one extra row params = (REPO / "params.yaml").read_text().replace("path: data/payments.csv", f"path: {tmp_path}/p.csv") (tmp_path / "params.yaml").write_text(params) r = subprocess.run([sys.executable, "scripts/evaluate_model.py", "--params", str(tmp_path / "params.yaml"), "--out", str(tmp_path / "v.json")], capture_output=True, text=True, env={**os.environ, "PYTHONPATH": "src"}, cwd=REPO) assert r.returncode == 4 and "does not match" in r.stdoutStep 4: Run them
scripts/registry.sh startpython scripts/evaluate_model.pypython scripts/gate.py; echo "exit=$?"pytest -q testsExpected output (real run, no champion yet):
val_pr_auc=0.2121 val_recall=0.8229 threshold=0.31 positives=96PASS val_pr_auc >= floor: 0.2121 vs 0.18PASS val_recall >= floor: 0.8229 vs 0.65PASS trained on the evaluation data: d526b22fPASS champion comparison: no champion yet: first release still needs approvalGATE PASSEDexit=013 passed, 1 warning in 49.40sThe first model passes the champion check automatically because there is no champion. It still becomes champion only after a human approves it in the next milestone.
Acceptance check
-
metrics/val.jsoncontains full-precision metrics,data_md5,trained_on_data_md5andeval_config_sha. -
pytest -q testsshows 13 passed.
Milestone 4: Register, Test Once and Promote the First Champion
This milestone writes the release scripts and runs them by hand once, so the registry has a first champion before any automation exists.
Three rules these scripts enforce
- Exact versions. Each step hands the next an exact version number, never "latest" or an alias, because "latest" can change between approval and promotion. Registration is idempotent per commit.
- Test once, recoverably.
test_once.pychecks three data fingerprints (bytes on disk, Git's pointer, the version's tag) and the split configuration, evaluates first, then publishes the report atomically. If the report exists, it refuses a second evaluation but repairs missing registry tags, so a crash between "report written" and "tags written" never blocks the version forever. The test report is the final honest number, not a promotion gate: promotion requires that it exists, not a minimum test score. - Recoverable promotion.
promote_ci.pywrites an intent file (pending.json) before changing anything, then moves the alias, the baseline, the tag and the audit log in steps that are safe to repeat, verifies alias and baseline, and only then deletes the intent. A crash half way leavespending.json; every later call exits 5 until--recoverfinishes the change.--expect-championrefuses if the champion changed since the approver looked.
Step 1: Write the release scripts
scripts/register_candidate.py:
"""Register the gated model as a new exact version. Runs on main only. Exit codes: 0 = registered (or already registered for this commit), 1 = gate not passed, 4 = bad input."""import jsonimport sysfrom pathlib import Path import joblibimport mlflowimport yaml from registry_util import MODEL_NAME, client LINEAGE = ("git_sha", "data_md5", "eval_config_sha", "model_sha256", "threshold", "val_pr_auc", "val_recall") def flat(d: dict, prefix: str = "") -> dict: out = {} for k, v in d.items(): out.update(flat(v, f"{prefix}{k}.") if isinstance(v, dict) else {f"{prefix}{k}": v}) return out def main() -> int: try: m = json.loads(Path("metrics/val.json").read_text()) gate = json.loads(Path("metrics/gate.json").read_text()) except FileNotFoundError as err: print(f"ERROR: {err}"); return 4 if not gate["passed"] or gate["model_sha256"] != m["model_sha256"]: print("REFUSED: gate did not pass for this exact model"); return 1 c = client() # Idempotent: one registered version per commit. A rerun returns the same version. if c.search_registered_models(filter_string=f"name='{MODEL_NAME}'"): for mv in c.search_model_versions(f"name='{MODEL_NAME}'"): if mv.tags.get("git_sha") == m["git_sha"]: print(f"already registered: {MODEL_NAME} version {mv.version}") Path("metrics/registered.json").write_text(json.dumps({"version": mv.version})) return 0 mlflow.set_experiment("acme-fraud-release") with mlflow.start_run(run_name=f"release-{m['git_sha'][:12]}"): mlflow.log_params(flat(yaml.safe_load(Path("params.yaml").read_text()))) mlflow.log_metrics({k: m[k] for k in ("val_pr_auc", "val_recall", "val_precision")}) mlflow.log_artifact("metrics/gate.json") # MLflow 3.18 saves scikit-learn models with skops, which refuses unknown types. # We trust this one type because this job trained the model from reviewed code. info = mlflow.sklearn.log_model( joblib.load("model/model.joblib"), name="model", skops_trusted_types=["sklearn.ensemble._hist_gradient_boosting.predictor.TreePredictor"]) tags = {k: repr(m[k]) if isinstance(m[k], float) else str(m[k]) for k in LINEAGE} # repr keeps full precision tags["gate"] = "passed" mv = mlflow.register_model(info.model_uri, MODEL_NAME, tags=tags) c.set_registered_model_alias(MODEL_NAME, "candidate", mv.version) # informational only Path("metrics/registered.json").write_text(json.dumps({"version": mv.version})) print(f"registered {MODEL_NAME} version {mv.version} (git {m['git_sha'][:12]}, data {m['data_md5'][:8]})") return 0 if __name__ == "__main__": sys.exit(main())TipMLflow 3.18 saves scikit-learn models with skops and refuses types it cannot audit.
HistGradientBoostingClassifierusesTreePredictor, solog_modelfails until you name that one type inskops_trusted_types. Trust a type only because you trained the model yourself, never to silence the error on a file from somewhere else.
scripts/test_once.py:
"""Score the test set once per registered version and record it in the registry. The test report is the final, honest number for a version. It is NOT a promotiongate: promotion needs the report to exist, not a minimum test score.Exit codes: 0 = report written, 1 = evaluation failed (nothing written, retry allowed), 3 = already tested (missing registry tags are repaired), 4 = bad input."""import argparseimport jsonimport osimport sysfrom datetime import datetime, timezonefrom pathlib import Path import mlflowfrom sklearn.metrics import average_precision_score, recall_score from acme_fraud.data import FEATURES, dvc_md5, eval_config_sha, file_md5, load_params, load_splitsfrom registry_util import MODEL_NAME, client, get_version REPORTS = Path(".registry/test_reports") def tag_from_report(c, v: str, report: dict) -> None: """Idempotent: safe to run again after a crash between report and tags.""" for k in ("test_pr_auc", "test_recall"): c.set_model_version_tag(MODEL_NAME, v, k, repr(report[k])) c.set_model_version_tag(MODEL_NAME, v, "tested", "true") def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--version", required=True) v = ap.parse_args().version c = client() mv = get_version(c, v) if mv is None: print(f"ERROR: {MODEL_NAME} version {v} not found"); return 4 params = load_params() pointer, actual = dvc_md5(), file_md5(params["data"]["path"]) # three-way check: bytes on disk == Git's pointer == what the version was judged on if not (actual == pointer == mv.tags.get("data_md5")): print(f"ERROR: data mismatch: file {actual[:8]}, pointer {pointer[:8]}, version {mv.tags.get('data_md5', '?')[:8]}"); return 4 if eval_config_sha(params) != mv.tags.get("eval_config_sha"): print("ERROR: split/evaluation config differs from the one this version was judged with"); return 4 REPORTS.mkdir(parents=True, exist_ok=True) report_path, lock = REPORTS / f"v{v}.json", REPORTS / f".v{v}.lock" if report_path.exists(): report = json.loads(report_path.read_text()) if report["version"] != v or report["data_md5"] != actual: print(f"ERROR: {report_path} does not belong to version {v} on this data"); return 4 tag_from_report(c, v, report) print(f"REFUSED: version {v} was already tested (registry tags checked and repaired)."); return 3 try: os.close(os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY)) except FileExistsError: print(f"REFUSED: {lock} exists (another run, or a crashed one: check, then delete)."); return 3 try: *_, test = load_splits(params) model = mlflow.sklearn.load_model(f"models:/{MODEL_NAME}/{v}") # exact version, never an alias proba = model.predict_proba(test[FEATURES])[:, 1] report = { "version": v, "data_md5": actual, "eval_config_sha": eval_config_sha(params), "test_pr_auc": float(average_precision_score(test["is_fraud"], proba)), "test_recall": float(recall_score(test["is_fraud"], proba >= float(mv.tags["threshold"]))), "rows": int(len(test)), "positives": int(test["is_fraud"].sum()), "evaluated_at": datetime.now(timezone.utc).isoformat(timespec="seconds"), } tmp = report_path.with_suffix(".json.tmp") tmp.write_text(json.dumps(report, indent=2)) os.link(tmp, report_path) # publish; fails if the report appeared meanwhile tmp.unlink() except Exception as err: print(f"EVALUATION FAILED, no report written: {err}"); return 1 finally: lock.unlink(missing_ok=True) tag_from_report(c, v, report) print(f"version {v}: test_pr_auc={report['test_pr_auc']:.4f} test_recall={report['test_recall']:.4f} " f"rows={report['rows']} positives={report['positives']}") return 0 if __name__ == "__main__": sys.exit(main())scripts/promote_ci.py:
"""Move the champion alias to an EXACT version, after a human approved it. --release-mode promote : version must have passed the gate and its one test run--release-mode rollback: version must have been champion beforeEvery change is written as an intent file first and applied in idempotent steps,so a crash half way is finished with --recover instead of leaving alias, baselineand audit log disagreeing.Exit codes: 0 = done, 1 = refused or verification failed, 3 = already champion, 4 = bad input, 5 = an unfinished change exists (run --recover)."""import argparseimport jsonimport osimport sysfrom datetime import datetime, timezonefrom pathlib import Path from registry_util import MODEL_NAME, client, current_champion, get_version def write_json(path: Path, data: dict) -> None: tmp = path.with_suffix(".tmp") tmp.write_text(json.dumps(data, indent=2)) os.replace(tmp, path) # atomic on one filesystem def apply(c, intent: dict, base_dir: Path, pending: Path) -> int: """Each step is safe to repeat.""" c.set_registered_model_alias(MODEL_NAME, "champion", intent["to"]) write_json(base_dir / "champion.json", intent["baseline"]) c.set_model_version_tag(MODEL_NAME, intent["to"], "was_champion", "true") audit = base_dir.parent / "audit.log" line = (f"{intent['op_id']} {intent['mode']} champion {intent['from']} -> v{intent['to']} " f"approved_by={intent['approved_by']} requested_by={intent['requested_by']}\n") if not (audit.exists() and intent["op_id"] in audit.read_text()): with open(audit, "a") as f: f.write(line) # verify before declaring success champ = current_champion(c) on_disk = json.loads((base_dir / "champion.json").read_text()) if champ is None or str(champ.version) != intent["to"] or on_disk["version"] != intent["to"]: print("ERROR: verification failed; pending.json kept, run --recover"); return 1 pending.unlink() verb = "promoted" if intent["mode"] == "promote" else "rollback initiated (alias moved)" print(f"{verb}: champion {intent['from']} -> v{intent['to']} (verified alias and baseline)") return 0 def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--version") ap.add_argument("--release-mode", choices=["promote", "rollback"]) ap.add_argument("--baseline-dir", default=".registry/baseline") ap.add_argument("--approved-by", help="the person who approved, as verified by the caller") ap.add_argument("--requested-by", default="unknown", help="the person or event that asked for it") ap.add_argument("--expect-champion", help="refuse if @champion is not this version ('none' = no champion)") ap.add_argument("--recover", action="store_true", help="finish an interrupted change") a = ap.parse_args() c = client() base_dir = Path(a.baseline_dir); base_dir.mkdir(parents=True, exist_ok=True) pending = base_dir.parent / "pending.json" if a.recover: if not pending.exists(): print("nothing to recover"); return 0 intent = json.loads(pending.read_text()) champ = current_champion(c) now = f"v{champ.version}" if champ else "none" # versions compared as strings if now not in (intent["from"], f"v{intent['to']}"): print(f"ERROR: champion is {now}, neither side of the pending change; resolve by hand"); return 1 print(f"recovering {intent['op_id']}: {intent['from']} -> v{intent['to']}") return apply(c, intent, base_dir, pending) if not (a.version and a.release_mode and a.approved_by): print("ERROR: --version, --release-mode and --approved-by are required"); return 4 if pending.exists(): print(f"ERROR: unfinished change in {pending}; run with --recover first"); return 5 mv = get_version(c, a.version) if mv is None: print(f"ERROR: {MODEL_NAME} version {a.version} not found"); return 4 champ = current_champion(c) old = f"v{champ.version}" if champ else "none" if a.expect_champion and a.expect_champion.lstrip("v") != old.lstrip("v"): print(f"REFUSED: champion is {old}, but the approver saw {a.expect_champion}. Re-approve."); return 1 if champ and str(champ.version) == str(mv.version): print(f"version {mv.version} is already champion, nothing to do"); return 3 t = mv.tags if a.release_mode == "promote" and not (t.get("gate") == "passed" and t.get("tested") == "true"): print(f"REFUSED: version {mv.version} needs gate=passed and tested=true (has {t.get('gate')}, {t.get('tested')})"); return 1 if a.release_mode == "rollback" and t.get("was_champion") != "true": print(f"REFUSED: version {mv.version} was never champion, so it is not a rollback target"); return 1 intent = { "op_id": datetime.now(timezone.utc).isoformat(timespec="seconds"), "mode": a.release_mode, "from": old, "to": str(mv.version), # str: SQLite and REST stores differ "approved_by": a.approved_by, "requested_by": a.requested_by, "baseline": {"version": str(mv.version), "val_pr_auc": float(t["val_pr_auc"]), "val_recall": float(t["val_recall"]), "threshold": float(t["threshold"]), "data_md5": t["data_md5"], "eval_config_sha": t["eval_config_sha"], "model_sha256": t["model_sha256"], "git_sha": t["git_sha"]}, } write_json(pending, intent) # 1. record the intent before changing anything return apply(c, intent, base_dir, pending) # 2. apply, verify, then clear the intent if __name__ == "__main__": sys.exit(main())scripts/whoserves.py:
"""Show what a service loading models:/acme-fraud@champion would get right now."""import mlflow from registry_util import MODEL_NAME, client, current_champion champ = current_champion(client())if champ is None: print("no champion yet")else: model = mlflow.sklearn.load_model(f"models:/{MODEL_NAME}@champion") print(f"@champion -> version {champ.version}, git {champ.tags['git_sha'][:12]}, " f"model {champ.tags['model_sha256'][:12]}, loaded {type(model).__name__}")Step 2: Commit, then run the first release by hand
You are the approver for this first release, so you record yourself. In CI the approver comes from GitHub's approval record (Milestone 6).
git add -A && git commit -m "release scripts"dvc repro && python scripts/evaluate_model.py && python scripts/gate.pypython scripts/register_candidate.pypython scripts/register_candidate.py # rerun: same version, no duplicatepython scripts/test_once.py --version 1python scripts/test_once.py --version 1; echo "exit=$?"python scripts/promote_ci.py --version 1 --release-mode promote \ --approved-by "$(git config user.name)" --requested-by "$(git config user.name)" --expect-champion nonepython scripts/whoserves.pycat .registry/baseline/champion.jsonExpected output (real run; MLflow's run links trimmed):
Successfully registered model 'acme-fraud'.Created version '1' of model 'acme-fraud'.registered acme-fraud version 1 (git bfe3c4760994, data d526b22f)already registered: acme-fraud version 1version 1: test_pr_auc=0.1503 test_recall=0.8417 rows=4000 positives=120exit=0REFUSED: version 1 was already tested (registry tags checked and repaired).exit=3promoted: champion none -> v1 (verified alias and baseline)@champion -> version 1, git bfe3c4760994, model c609f5f91f7b, loaded Pipeline{ "version": "1", "val_pr_auc": 0.21210293312658604, "val_recall": 0.8229166666666666, "threshold": 0.31, "data_md5": "d526b22f540789e8f128b70217bb58ea", "eval_config_sha": "ff096e10f9ec3b76", "model_sha256": "c609f5f91f7b23ecf9ac91ca99212a356d96891a4183935c3cae291fc0eb18d7", "git_sha": "bfe3c4760994c4e70af7228ebddc62bb5c8e65ad"}Test PR-AUC (0.150) is below validation (0.212). With 120 fraud rows in test, that gap is within what sampling noise produces; report both numbers with their row counts.
Step 3: Save the registry state and publish everything
PUSH=0 scripts/save_registry.sh "release v1" # no remote yet, so commit onlygh repo create acme-fraud-cicd --public --source . --pushgit push origin dvc-store registry-stateAcceptance check
-
.registry/baseline/champion.jsonnames version 1 and includesdata_md5andeval_config_sha. -
.registry/audit.loghas onepromoteline withapproved_byandrequested_by, and nopending.jsonexists.
SecurityThe repository is public because branch protection and environment reviewers are free on public repositories. Never put real customer data on
dvc-store. This dataset is synthetic.
Milestone 5: Block a Worse Model in a Pull Request
This milestone adds the two-job PR workflow, protects main so the judge check must pass before merging, and opens a pull request that makes the model worse.
Step 1: Write the PR workflow
.github/workflows/pr-gate.yml:
name: pr-gateon: pull_request: branches: [main] permissions: contents: read # read-only token; no secrets anywhere in this workflow concurrency: group: pr-gate-${{ github.event.pull_request.number }} cancel-in-progress: true jobs: # Job 1 runs the PR's code. Its only output is the model file. train: runs-on: ubuntu-24.04 steps: # Pinned by commit SHA: checkout v7.0.1, setup-python v7.1.0, upload-artifact v7.0.2, download-artifact v7.0.0 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: persist-credentials: false - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: ref: dvc-store path: .dvc-store persist-credentials: false - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 with: python-version: "3.12" - run: pip install -r requirements.txt - run: dvc pull data/payments.csv && dvc repro train env: PYTHONPATH: src - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 with: name: candidate-model path: model/ retention-days: 7 # Job 2 never checks out the PR. Main's code, rules, data pointer and data judge the model. judge: needs: train runs-on: ubuntu-24.04 env: PYTHONPATH: src MLFLOW_DISABLE_AGENT_HINT: "1" steps: - name: Check out main only (trusted) uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: ref: main persist-credentials: false - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: ref: dvc-store path: .dvc-store persist-credentials: false - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: ref: registry-state path: .registry persist-credentials: false - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 with: python-version: "3.12" - run: pip install -r requirements.txt # main's pinned libraries - run: dvc pull data/payments.csv # main's data version - name: Download only the PR's model file uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 with: name: candidate-model path: candidate/ - name: Evaluate and gate run: | python scripts/evaluate_model.py --model-dir candidate --params params.yaml \ --dvc-file data/payments.csv.dvc --out metrics/val.json python scripts/gate.py --params params.yaml --baseline .registry/baseline/champion.json - if: always() uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 with: name: gate-${{ github.event.pull_request.number }} path: metrics/| Choice | Protects against | Where |
|---|---|---|
Judge checks out only main |
PR changing scripts, rules, split or data | judge job |
| Model file is the only handoff | PR files reaching the judge | upload / download |
contents: read, no secrets |
A PR stealing credentials | permissions |
persist-credentials: false, SHA pins |
Token reuse, moved tags | every checkout |
Step 2: Protect main on GitHub
Commit and push the workflow, then in the repository settings:
- Branches, add a rule for
main: require a pull request, require the status checkjudgeto pass, and do not allow bypassing. - Environments, new environment
production: add one or more required reviewers, enable "prevent self-review" if you have a teammate, and limit deployment branches tomain.
(Described from GitHub's documentation, not observed for this guide.)
Step 3: Open a pull request that makes the model worse
git switch -c pr/fewer-treessed -i 's/max_iter: 200/max_iter: 10/; s/learning_rate: 0.05/learning_rate: 0.01/' params.yamldvc reprogit commit -am "fewer, slower trees"Judge it locally the way the judge job does: copy the model file out, switch to main, and evaluate with main's files:
rm -rf ../candidate && cp -r model ../candidategit switch main && dvc checkoutpython scripts/evaluate_model.py --model-dir ../candidate --params params.yaml --dvc-file data/payments.csv.dvcpython scripts/gate.py; echo "exit=$?"Expected output (real run):
trained model sha256=6e073c94ded9 threshold=0.47val_pr_auc=0.1206 val_recall=0.8750 threshold=0.47 positives=96FAIL val_pr_auc >= floor: 0.1206 vs 0.18PASS val_recall >= floor: 0.8750 vs 0.65PASS trained on the evaluation data: d526b22fPASS same evaluation data as champion: data d526b22f vs d526b22f, config ff096e10 vs ff096e10FAIL not worse than champion: 0.1206 vs 0.2021 (champion v1 = 0.2121)GATE FAILEDexit=1Push the branch and open the pull request (git push -u origin pr/fewer-trees, then gh pr create --fill). On GitHub the judge check should turn red and the merge button stay disabled. Screenshot it, then close the PR.
Acceptance check
- The PR shows a failed
judgecheck and cannot be merged. - The
gate-<number>artifact containsgate.jsonwith the failed checks.
Common MistakeSwitching the PR workflow to
pull_request_target"so it can read secrets". That trigger runs with the base repository's permissions and secrets while you build untrusted code. Keeppull_requestand keep secrets out of it.
Milestone 6: Merge a Good Model and Promote It with Approval
This milestone adds the release workflow: a merge to main retrains, gates, registers an exact version and scores the test set once; the promote job then waits for a reviewer, reads who approved from GitHub's record, and moves champion only if it is still the version the approver saw.
Step 1: Write the release workflow
.github/workflows/release.yml:
name: releaseon: push: branches: [main] # every merge: retrain, gate, register, test once workflow_dispatch: # someone with write access asks for a promote or rollback inputs: mode: type: choice options: [promote, rollback] required: true version: description: Exact registry version number, for example 2 type: string required: true permissions: contents: read concurrency: group: registry # one writer of the registry branch at a time cancel-in-progress: false queue: max # queue up to 100 runs; without this a newer pending run replaces an older one env: MLFLOW_DISABLE_AGENT_HINT: "1" PYTHONPATH: src jobs: register: if: github.event_name == 'push' runs-on: ubuntu-24.04 permissions: contents: write # to push the registry-state branch outputs: version: ${{ steps.reg.outputs.version }} champion: ${{ steps.reg.outputs.champion }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: persist-credentials: false - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: ref: dvc-store path: .dvc-store persist-credentials: false - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: ref: registry-state path: .registry # credentials kept here only, to push registry state - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 with: python-version: "3.12" - run: pip install -r requirements.txt - run: dvc pull data/payments.csv && dvc repro train - run: scripts/registry.sh start - name: Evaluate, gate, register exact version, test once id: reg run: | python scripts/evaluate_model.py python scripts/gate.py python scripts/register_candidate.py VERSION=$(python -c "import json; print(json.load(open('metrics/registered.json'))['version'])") CHAMPION=$(python -c "import json,os; p='.registry/baseline/champion.json'; print(json.load(open(p))['version'] if os.path.exists(p) else 'none')") echo "version=${VERSION}" >> "$GITHUB_OUTPUT" echo "champion=${CHAMPION}" >> "$GITHUB_OUTPUT" python scripts/test_once.py --version "$VERSION" || [ $? -eq 3 ] # 3 = already tested on a rerun { echo "### acme-fraud v${VERSION} (current champion: ${CHAMPION})" echo '```json'; cat metrics/gate.json; cat ".registry/test_reports/v${VERSION}.json"; echo '```' } >> "$GITHUB_STEP_SUMMARY" - name: Save registry state (fails the job if the push fails) if: always() run: scripts/save_registry.sh "register ${GITHUB_SHA::12}" promote: needs: register runs-on: ubuntu-24.04 environment: production # a required reviewer must approve before this job starts permissions: contents: write actions: read # to read who approved this run steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: persist-credentials: false - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: ref: registry-state path: .registry - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 with: python-version: "3.12" - run: pip install -r requirements.txt - name: Find the reviewer who approved production for this run id: who env: GH_TOKEN: ${{ github.token }} RUN_ID: ${{ github.run_id }} run: | REVIEWER=$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${RUN_ID}/approvals" \ --jq '[.[] | select(.state=="approved") | select(any(.environments[]; .name=="production")) | .user.login] | last // empty') [ -n "$REVIEWER" ] || { echo "no recorded approval for production"; exit 1; } echo "reviewer=${REVIEWER}" >> "$GITHUB_OUTPUT" - run: scripts/registry.sh start - name: Promote the exact version that was approved env: VERSION: ${{ needs.register.outputs.version }} EXPECTED: ${{ needs.register.outputs.champion }} REVIEWER: ${{ steps.who.outputs.reviewer }} REQUESTER: ${{ github.triggering_actor }} run: | # exit 3 = already champion (a rerun): nothing to do, not a failure python scripts/promote_ci.py --version "$VERSION" --release-mode promote \ --approved-by "$REVIEWER" --requested-by "$REQUESTER" --expect-champion "$EXPECTED" || [ $? -eq 3 ] - name: Save registry state (fails the job if the push fails) if: always() run: scripts/save_registry.sh "promote v${{ needs.register.outputs.version }}" manual: if: github.event_name == 'workflow_dispatch' runs-on: ubuntu-24.04 environment: production permissions: contents: write actions: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: persist-credentials: false - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: ref: registry-state path: .registry - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 with: python-version: "3.12" - run: pip install -r requirements.txt - name: Find the reviewer who approved production for this run id: who env: GH_TOKEN: ${{ github.token }} RUN_ID: ${{ github.run_id }} run: | REVIEWER=$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${RUN_ID}/approvals" \ --jq '[.[] | select(.state=="approved") | select(any(.environments[]; .name=="production")) | .user.login] | last // empty') [ -n "$REVIEWER" ] || { echo "no recorded approval for production"; exit 1; } echo "reviewer=${REVIEWER}" >> "$GITHUB_OUTPUT" - run: scripts/registry.sh start - name: Promote or roll back an exact version env: # inputs go through env, never pasted into the script MODE: ${{ inputs.mode }} VERSION: ${{ inputs.version }} REVIEWER: ${{ steps.who.outputs.reviewer }} REQUESTER: ${{ github.triggering_actor }} run: | [[ "$VERSION" =~ ^[0-9]+$ ]] || { echo "version must be a number"; exit 4; } python scripts/promote_ci.py --version "$VERSION" --release-mode "$MODE" \ --approved-by "$REVIEWER" --requested-by "$REQUESTER" - name: Save registry state (fails the job if the push fails) if: always() env: MODE: ${{ inputs.mode }} VERSION: ${{ inputs.version }} run: scripts/save_registry.sh "${MODE} to v${VERSION}"Four details:
- Queueing.
concurrencywithcancel-in-progress: falsealone keeps one running and one pending run; a third run replaces the pending one, which would silently skip a merge.queue: maxqueues up to 100 runs in order (GitHub changelog, 7 May 2026). - Who approved.
github.actorandgithub.triggering_actorare who started the run, not who approved it. Thepromotejob reads the approval record fromGET /repos/{owner}/{repo}/actions/runs/{run_id}/approvalsand fails if noproductionapproval is found.requested_byandapproved_byare stored separately. This API call was not run for this guide; check the field names against the GitHub REST docs before relying on it. - Stale approvals.
registerrecords the champion at the time the summary was written, andpromotepasses it as--expect-champion. If someone promoted in between, the promotion is refused and needs a fresh approval. - Inputs through
env:.inputs.versionis never pasted into the script text, so a value like2; curl evilcannot run, and the script checks it is a number.
Step 2: Open a good pull request and merge it
git switch main && git switch -c pr/smaller-leavesgrep max_leaf params.yamlsed -i 's/max_leaf_nodes: 31/max_leaf_nodes: 15/' params.yamlgrep max_leaf params.yamldvc repro && git commit -am "smaller trees: max_leaf_nodes 15"rm -rf ../candidate && cp -r model ../candidate && git switch main && dvc checkoutpython scripts/evaluate_model.py --model-dir ../candidate && python scripts/gate.py; echo "exit=$?"Expected output (real run):
max_leaf_nodes: 31 max_leaf_nodes: 15trained model sha256=68494a4fe9f4 threshold=0.34val_pr_auc=0.2272 val_recall=0.8333 threshold=0.34 positives=96PASS val_pr_auc >= floor: 0.2272 vs 0.18PASS val_recall >= floor: 0.8333 vs 0.65PASS trained on the evaluation data: d526b22fPASS same evaluation data as champion: data d526b22f vs d526b22f, config ff096e10 vs ff096e10PASS not worse than champion: 0.2272 vs 0.2021 (champion v1 = 0.2121)GATE PASSEDexit=0Validation PR-AUC moved by 0.015 on 96 fraud rows. Claim "not worse", not "better".
git push -u origin pr/smaller-leaves && gh pr create --fill # wait for a green judge, then mergeStep 3: Approve the promotion
After the merge, the release run starts. register writes a job summary with gate.json, the test report and the current champion. promote then shows "Waiting for review". Read the summary, click Review deployments, tick production, and approve.
The same release, run locally for this guide after git merge --no-ff pr/smaller-leaves (real run):
Stage 'train' is cached - skipping run, checking out outputsval_pr_auc=0.2272 val_recall=0.8333 threshold=0.34 positives=96GATE PASSEDCreated version '2' of model 'acme-fraud'.registered acme-fraud version 2 (git 855754cc6b8d, data d526b22f)version 2: test_pr_auc=0.1599 test_recall=0.8833 rows=4000 positives=120exit=0promoted: champion v1 -> v2 (verified alias and baseline)exit=0@champion -> version 2, git 855754cc6b8d, model 68494a4fe9f4, loaded PipelineThe promote command was promote_ci.py --version 2 --release-mode promote --approved-by reviewer-a --requested-by kushagra --expect-champion 1, standing in for what the workflow passes.
Acceptance check
- The release run shows a named approval before
promoteran, and the audit line names that reviewer asapproved_by. - On the
registry-statebranch,baseline/champion.jsonnames version 2.
Common MistakePromoting
@candidateor "the latest version" instead of the number the approver saw. Between approval and promotion another merge can register version 3, and you would ship a model nobody reviewed.
Milestone 7: Roll Back to the Previous Champion
This milestone moves champion back to version 1 through the same approved path, and shows what a rollback does and does not change.
What a rollback here does, and does not do
promote_ci.py --release-mode rollback moves the champion alias and the baseline (including its data md5 and split fingerprint) back to an exact version that was champion before. It prints "rollback initiated (alias moved)" on purpose:
- A service that loads
models:/acme-fraud@championserves the old model only after it reloads or restarts. - The version carries references to its code and data (
git_sha,data_md5), not the code and data themselves. They tell you exactly what to check out to rebuild it; they do not change any running service's code.
Step 1: Run the rollback
On GitHub: Actions, release, Run workflow, mode rollback, version 1, then approve production. Locally:
scripts/registry.sh startpython scripts/promote_ci.py --version 1 --release-mode rollback --approved-by reviewer-a --requested-by kushagra; echo "exit=$?"python scripts/whoserves.pypython scripts/promote_ci.py --version 1 --release-mode rollback --approved-by reviewer-a --requested-by kushagra; echo "exit=$?"cat .registry/audit.logExpected output (real run):
rollback initiated (alias moved): champion v2 -> v1 (verified alias and baseline)exit=0@champion -> version 1, git bfe3c4760994, model c609f5f91f7b, loaded Pipelineversion 1 is already champion, nothing to doexit=32026-11-05T04:17:41+00:00 promote champion none -> v1 approved_by=kushagra requested_by=kushagra2026-11-05T04:18:29+00:00 promote champion v1 -> v2 approved_by=reviewer-a requested_by=kushagra2026-11-05T04:18:34+00:00 rollback champion v2 -> v1 approved_by=reviewer-a requested_by=kushagraThe baseline followed the alias, so the next pull request is compared with version 1 again.
Acceptance check
-
whoserves.pyshows version 1, andbaseline/champion.jsonnames version 1 with its owndata_md5. -
PUSH=0 scripts/save_registry.sh "rollback"(or the workflow) committed the change.
TipIn a real incident, a second environment such as
rollbackwith one on-call reviewer gets you back faster than the full release approval. Keep the same script; change only who must approve.
Milestone 8: Prove a Version Is Reproducible
This milestone rebuilds version 2 from a fresh clone at its commit with its data version, and checks that the model file and every registered metric come out identical.
Step 1: Write the check
scripts/repro_check.py:
"""Prove a registered version is reproducible: same commit + same data = same model and metrics. Exit codes: 0 = reproduced exactly, 1 = mismatch, 4 = wrong commit or data checked out."""import argparseimport jsonimport subprocessimport sysfrom pathlib import Path from acme_fraud.data import dvc_md5from registry_util import client, get_version def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--version", required=True) v = ap.parse_args().version mv = get_version(client(), v) head = subprocess.run(["git", "rev-parse", "HEAD"], capture_output=True, text=True).stdout.strip() if mv is None or mv.tags["git_sha"] != head or mv.tags["data_md5"] != dvc_md5(): print(f"ERROR: check out commit {mv.tags['git_sha'][:12] if mv else '?'} and run dvc pull first"); return 4 subprocess.run(["dvc", "repro", "--force", "train"], check=True, capture_output=True) # retrain from scratch subprocess.run([sys.executable, "scripts/evaluate_model.py", "--out", "metrics/repro.json"], check=True, capture_output=True) new = json.loads(Path("metrics/repro.json").read_text()) ok = True for key in ("model_sha256", "val_pr_auc", "val_recall", "threshold", "eval_config_sha"): rebuilt = repr(new[key]) if isinstance(new[key], float) else str(new[key]) same = rebuilt == mv.tags[key] ok &= same print(f"{'SAME' if same else 'DIFF'} {key}: registered={mv.tags[key][:12]} rebuilt={rebuilt[:12]}") print("REPRODUCED (on this machine and library set)" if ok else "NOT REPRODUCED") return 0 if ok else 1 if __name__ == "__main__": sys.exit(main())Step 2: Rebuild from nothing
cd /tmp && git clone https://github.com/<you>/acme-fraud-cicd repro && cd reprogit worktree add .dvc-store origin/dvc-storegit checkout <git_sha tag of version 2>dvc pullexport PYTHONPATH=srcpython scripts/repro_check.py --version 2 # the MLflow server from your project folder is still runningExpected output (real run, from a fresh clone on the same machine):
SAME model_sha256: registered=68494a4fe9f4 rebuilt=68494a4fe9f4SAME val_pr_auc: registered=0.2272228002 rebuilt=0.2272228002SAME val_recall: registered=0.8333333333 rebuilt=0.8333333333SAME threshold: registered=0.34 rebuilt=0.34SAME eval_config_sha: registered=ff096e10f9ec rebuilt=ff096e10f9ecREPRODUCED (on this machine and library set)exit=0Byte-identical model files depend on the same library versions and CPU family. On another machine the metrics usually match closely even when the file hash differs. Report what you observed and where.
Break It on Purpose
These exercises break the exact things the pipeline trusts. Each ran for this guide; outputs are real.
Step 1: Corrupt the data file the pipeline reads
echo "txn_999999,1.0,3,fuel,0,10,1,0" >> data/payments.csvdvc statuspython scripts/evaluate_model.py; echo "exit=$?"dvc checkout --force data/payments.csv.dvcpython scripts/evaluate_model.py; echo "exit=$?"Expected output:
train: changed deps: modified: data/payments.csvERROR: data/payments.csv does not match data/payments.csv.dvc (run: dvc checkout --force)exit=4val_pr_auc=0.2272 val_recall=0.8333 threshold=0.34 positives=96exit=0test_once.py runs the same three-way check, so a modified CSV can never produce a test report either.
Step 2: Make a pull request edit the gate
git switch -c pr/sneakysed -i 's/max_iter: 200/max_iter: 10/; s/learning_rate: 0.05/learning_rate: 0.01/' params.yamlsed -i 's/ passed = all(ok for _, ok, _ in checks)/ passed = True # "temporary"/' scripts/gate.pydvc repro && python scripts/evaluate_model.py > /dev/nullpython scripts/gate.py | tail -1 # the PR's own, edited gategit commit -qam sneaky && rm -rf ../candidate && cp -r model ../candidategit switch main && dvc checkoutpython scripts/evaluate_model.py --model-dir ../candidate > /dev/nullpython scripts/gate.py | tail -3 # what the judge job runsgit branch -D pr/sneakyExpected output:
GATE PASSEDPASS same evaluation data as champion: data d526b22f vs d526b22f, config ff096e10 vs ff096e10FAIL not worse than champion: 0.1165 vs 0.2021 (champion v1 = 0.2121)GATE FAILEDThe PR's gate says yes. The judge never sees the PR's gate.py, params.yaml or data pointer, so it says no.
Step 3: Try to promote a version that skipped the test run
git commit --allow-empty -m "rebuild for v3"python scripts/evaluate_model.py > /dev/null && python scripts/gate.py > /dev/nullpython scripts/register_candidate.pypython scripts/promote_ci.py --version 3 --release-mode promote --approved-by me; echo "exit=$?"python scripts/promote_ci.py --version 3 --release-mode rollback --approved-by me; echo "exit=$?"Expected output:
registered acme-fraud version 3 (git a58cd3c524e6, data d526b22f)REFUSED: version 3 needs gate=passed and tested=true (has passed, None)exit=1REFUSED: version 3 was never champion, so it is not a rollback targetexit=1Step 4: Lose a registry tag and recover it without a second test run
Simulate a crash between "test report written" and "tags written" by deleting the tested tag of version 2:
python -c "import sys; sys.path.insert(0, 'scripts'); from registry_util import client; client().delete_model_version_tag('acme-fraud', '2', 'tested')"python scripts/promote_ci.py --version 2 --release-mode promote --approved-by me; echo "exit=$?"python scripts/test_once.py --version 2; echo "exit=$?"Expected output:
REFUSED: version 2 needs gate=passed and tested=true (has passed, None)exit=1REFUSED: version 2 was already tested (registry tags checked and repaired).exit=3The test set was not scored again, and the version is promotable again. The same idea for promotion, an interrupted change finished with --recover, is covered by test_interrupted_change_blocks_until_recovered.
Validation & Testing
Run the checks below, then tear down. Nothing in this project costs money.
Step 1: Final checks
pytest -q tests # 13 passedgit worktree list # project, .dvc-store, .registrycat .registry/audit.log # promote, promote, rollbackls .registry/pending.json 2>/dev/null || echo "no unfinished change"git -C .registry log --oneline | head -5 # every registry change is a commitDefinition of done
- Data is tracked by DVC,
requirements.txtis a training dependency, and the remote is thedvc-storebranch. - The PR workflow has two jobs; the
judgejob checks out onlymainand receives only the model file. - One PR was blocked by the judge, and the screenshot is in the README.
- One PR was merged, registered as an exact version, tested once, and promoted after an approval whose reviewer is recorded as
approved_by. - One rollback moved
championand the baseline (with its data and split fingerprints) to an earlier exact version. -
repro_check.pyprintedREPRODUCEDfrom a fresh clone, and the README says on which machine. - The README says which steps ran on GitHub and which only locally.
Step 2: Tear down
scripts/registry.sh stopgit worktree remove .dvc-store && git worktree remove .registrydeactivateTo remove everything on GitHub, delete the repository in its settings. Nothing else was created.
Present This Project in an Interview
The 60-second version
"I built CI/CD for a fraud model with DVC, MLflow and GitHub Actions. A pull request's code trains in one job; a second job checks out only main and judges the model file with main's evaluator, rules, split and data, so a PR cannot change how it is graded. The gate needs the same data version as the champion, uses full-precision metrics, and the recall check is independent because the threshold is picked on a separate calibration split. A worse PR, validation PR-AUC 0.121 against a champion at 0.212, was blocked. A merged PR becomes an exact registry version, the test set is scored once, and promotion waits for a reviewer whose name comes from GitHub's approval record. Promotions and rollbacks are written as an intent first, so a crash is recovered, not left half done. On my machine, a fresh clone at the version's commit and data version rebuilt a byte-identical model."
Questions to expect
- "Can the PR still cheat?" Yes, two ways: its model file is unpickled by the judge, and its training code could train on validation rows. That is why the judge has no secrets, merges need review, and the release retrains from reviewed code before the one test run.
- "Why must the data match the champion's?" Otherwise you compare scores from two datasets. A dataset change needs a different policy: re-score both models on one fixed comparison set.
- "Why a tolerance instead of 'must be better'?" With 96 fraud rows in validation, a 0.01 move is noise. Strict improvement would reward luck and block harmless refactors.
- "What if two merges land close together?"
queue: maxruns them in order, one registry writer at a time, and--expect-championrefuses a promotion if the champion changed after approval. - "Is your rollback instant?" Moving the alias is. Serving changes when the service reloads
@champion, so in production I would pair it with a reload and check which version is answering. - "What would you change in a company?" A shared MLflow server and an S3 DVC remote instead of branches, OIDC credentials only on
mainjobs, and a faster rollback environment for on-call.
What to show on screen
- The blocked PR with its red
judgecheck. - The release run paused on "Waiting for review", then the audit line with the reviewer.
audit.logandwhoserves.pybefore and after the rollback.