Shipping AI changes safely: CI/CD for prompts, models and agents
Treat every prompt, model setting and tool change as a release: gate it with evaluation in CI, roll out as a canary, roll back fast, track model retirement.
An AI feature can change without anyone touching its code. Someone edits a sentence in the prompt. Someone moves to a newer model. Someone rewords a tool description so an agent picks it more often. Each of these changes what users get. Each is a production change.
Software teams already have a safe way to ship code: CI/CD. Every change goes through a pipeline that tests it, and only changes that pass reach users. The idea of this topic is simple: put the AI's settings into that same pipeline.
That means four habits:
Keep the AI's settings with the code. Prompts, model names, model settings and tool definitions live in files under version control, reviewed like code.
Test every change against an evaluation gate. The pipeline replays a fixed set of test cases. If quality drops below agreed limits, the change is blocked.
Roll out in small steps. A new version goes to a small group first (a canary). If it does worse than the current version, it is rolled back.
Watch the calendar. Model versions retire on fixed dates. The pipeline warns you before your model does.
Take the blocked sales order assistant used throughout this course. It drafts a triage note for each order on hold, and a credit analyst decides what to do.
A developer shortens the prompt to cut token costs. The change looks harmless and ships on a Friday. On Monday the notes start to explain what block code "01" means, and some advise releasing the order. Codes differ by system, and releasing is the analyst's call, not the assistant's. Nobody notices for a week, because nothing crashed.
With a release pipeline, that change never reaches users. The pull request runs the evaluation suite. Two must-pass cases fail: the note guesses a code meaning and gives release advice. The merge button stays grey, and the reason is in the report.
The value is the same as for any release discipline:
Fewer incidents. Regressions are caught before users see them, not after a week of wrong advice.
Faster, calmer change. When every change is tested the same way, teams change more often with less fear. Cheaper models and better prompts reach users sooner.
An audit trail. You can answer "what prompt and model were live on 3 March?" from Git history and release records. That matters for internal audit and for AI regulation.
No surprise outages. A model retirement becomes a planned change, not a Monday-morning failure.
The cost is modest: a pipeline, a test set you probably have from evaluation work, and the discipline to route every AI change through it.
SAP does not sell one "AI CI/CD" product. As of October 2026, the parts come from the generative AI hub in SAP AI Core and from SAP's general DevOps services on SAP BTP.
Prompt registry. The generative AI hub keeps prompt templates with a name, version and scenario. SAP Learning describes two ways to manage them: imperative, through an API with edit history, and declarative, where a YAML file in a Git repository is synced into the registry by SAP AI Core. SAP positions the declarative way for runtime apps and CI/CD (SAP Learning).
Stored orchestration configurations. Since February 2026, SAP's What's New log says the registry can also hold orchestration configurations declaratively: the model, prompt and other module settings as one versioned object (SAP AI Core service guide).
Evaluations. The same log lists Evaluations (December 2025) for benchmarking prompts and models through orchestration configurations. It is the SAP-side engine for the test step; Building an evaluation harness covers it.
Pipelines and transports.SAP Continuous Integration and Delivery is SAP's pipeline-as-a-service: a Git push triggers a predefined pipeline, which can hand a release candidate to SAP Cloud Transport Management. That service keeps a central change log (SAP Learning).
Model dates. Model versions in the generative AI hub retire on published dates. Running AI on SAP BTP in production explains where to read them and what happens on the day.
What SAP does not provide out of the box is the gate itself: the rule that says "this prompt change is blocked because must-pass cases fail". Your team writes that rule and runs it in whichever pipeline you use.
Use this table to decide what must go through the pipeline. If a change appears in the left column, it is a release, even if no code changed.
Change
Example
Why it is a release
Minimum check
Prompt text
One sentence reworded
Changes every answer
Full evaluation gate
Model or model version
Move to a newer or cheaper model
Different behavior, cost and latency
Gate, then canary
Model settings
Longer answer limit, different reasoning setting
Changes length, cost, sometimes accuracy
Gate
Tool definitions
New tool description for an agent
Changes which tool the agent calls
Gate with agent cases
Retrieval settings
New chunk size, different index
Changes what the model sees
Retrieval and answer gate
Evaluation suite
New test cases added
Changes the yardstick, not the system
Re-baseline in the same change
Library upgrade
New version of the model SDK
Can change what settings are sent
Gate
Provider-side update
"Latest" model alias moves
Behavior changes without your release
Scheduled re-run of the gate
The last row is the tricky one. If you let a provider move you to the latest model automatically, you avoid retirement outages but lose control over when behavior changes. Many teams pin a version in production and re-run the gate on the newer version in a test environment.
"A prompt edit isn't a deployment." It changes every answer the system gives. Treat it like a code change: review, test, release.
"Our unit tests cover it." Unit tests check code paths. They don't check whether the answers got worse.
"The evaluation gate makes a canary unnecessary." The gate tests the cases you wrote. Real users bring cases you didn't, so a small rollout still matters.
"Following the latest model version removes risk." It removes the retirement date, but behavior can change under you without a release.
"Rollback is just undoing the commit." Only if the old prompt, model and settings are still available. Retired models can't be rolled back to.
"Running the gate costs a fortune in model calls." Pull requests can replay recorded answers for free. Real calls are needed only when the AI's settings actually change.
Pick one answer for each question. The explanation appears after you choose.
1A developer rewords one sentence in the assistant's prompt. How should the team treat it?
Answer: A. A prompt change alters every answer the system gives, so it is a production change even though no code changed. It goes through the same pipeline as code: review, the evaluation gate, then release.
2The release gate passed. Why does the team still roll the new version out to a small group first?
Answer: B. The gate checks a fixed set of chosen cases. A canary exposes the change to real work on a small scale and compares it with a control group, so a problem the suite missed is caught before everyone sees it.
3What does SAP's generative AI hub offer for managing prompts the way code is managed?
Answer: A. As of October 2026 the prompt registry keeps versioned templates, and SAP describes a declarative mode where YAML files in a Git repository are synced into the registry for apps and CI/CD. The gate itself is still yours to write.
4Your team lets the provider move production to the latest model version automatically. What risk remains?
Answer: C. Following the latest version avoids a retirement outage, but the model can change behavior with no release on your side. That is why teams pin a version in production and re-run the gate on the newer one first.
5A leader asks how fast the team can return to last week's assistant if today's release goes wrong. What is the best sign that the answer is reliable?
Answer: A. Rollback works only when the previous prompt, model and settings are recorded and still available, and when the team has tried it. Promises and personal copies are not a plan.
6Which change is a release even though no file in the team's repository changed?
Answer: D. When a provider moves a "latest" alias, the model behind your assistant changes with no change on your side. A scheduled re-run of the gate is how you notice before users do.
Ask a question
Testing: only staff see this
Stuck on something in this layer? Ask it here. Questions are answered in the order they arrive, and the answer appears under My questions.
Sign in (free) to ask a question. You can ask anonymously.
The system you ship is not just your code. It is code + prompt + model + model settings + tool definitions, and each part can change on its own. A release pipeline treats that whole bundle as the thing that gets versioned, tested and released.
Give the bundle one fingerprint: a hash of the prompt text, the model settings and the tool definitions. Then the rules become mechanical:
A change to any part gives a new fingerprint, so it is a new release candidate.
A candidate can be released only if the gate passed on that fingerprint.
Test evidence (scores, recorded answers) is valid only for the fingerprint it was made with.
Rollback means making an older fingerprint live again, which works only if that model still exists.
flowchart LR
PR[Pull request<br/>prompt, model, tools] --> FP[Fingerprint]
FP --> G{Evaluation gate}
G -->|fail| BLOCK[Merge blocked<br/>report attached]
G -->|pass| REL[Release record]
REL --> CAN{Canary vs. control}
CAN -->|worse| RB[Rollback to<br/>previous release]
CAN -->|not worse| ALL[Full rollout]
CAL[Model retirement<br/>dates] -.weekly check.-> G
Put every behavior-defining file in the repository, next to the code that uses it:
File
Holds
Who reviews a change
prompts/triage_v1.txt
The prompt text, one file per version
Developer and process owner
settings.json
Provider, model, which prompt, model settings
Developer; architect for model moves
tools.json
Tool names, descriptions and input schemas
Developer and security for new tools
suite.jsonl
Evaluation cases with must-pass flags
Process owner
gate.json
Minimum scores and allowed drop
Business owner
Two rules make this work. First, the app reads these files at runtime, so what you test is what runs. Second, nobody edits a prompt in a production console. On SAP, the declarative prompt registry follows the same idea: the YAML in Git is the source, and SAP AI Core syncs it.
#The evaluation gate in CI, without paying for every pull request
Calling a real model on every pull request costs money, needs a secret in CI, and gives answers that vary between runs. A recorded model mock solves all three:
When the AI's settings change, a developer runs the suite once against the real model and saves the answers in a recordings file. Each answer is keyed by the candidate's fingerprint plus the case input.
The developer commits the recordings with the change. Reviewers see the new answers in the diff.
CI replays the recordings, scores them with rules and applies the gate. No key, no cost, same result every time.
The key detail is what happens when a recording is missing. If someone edits the prompt and does not re-record, the fingerprint changes and no recording matches. The gate must fail closed: "10 cases have no recording". A gate that skipped missing cases would pass a change nobody tested.
sequenceDiagram
participant Dev as Developer
participant LLM as Model API
participant Git as Repository
participant CI as CI job
Dev->>LLM: record --llm (new fingerprint)
LLM-->>Dev: answers per case
Dev->>Git: commit prompt + recordings
Git->>CI: pull request event
CI->>CI: replay recordings, score, gate
CI-->>Git: check passes or fails, report attached
The gate rules are the ones from Building an evaluation harness: minimum scores per rule, a maximum drop against the released version, and must-pass cases that may never fail. The released version's scores are the baseline.
A gate tests the cases you wrote. A canary tests the change on real work at small scale. Google's SRE workbook describes canarying as a partial, time-limited deployment that is evaluated against a control: the users still on the current version, in the same period (SRE Workbook). Comparing with the control matters because time changes behavior; last week's numbers are not a fair comparison.
For an AI assistant, pick a few signals tied to user impact:
Error rate: failed or unparsable responses.
Override rate: how often analysts decide differently from the suggestion. Evaluating in production explains why this is the strongest signal.
The decision has three outcomes. Rollback if the canary is worse on any signal and the gap is unlikely to be chance. Hold if there isn't enough traffic yet to tell. Promote otherwise. Write the thresholds down before the canary starts.
How you split traffic depends on the app. For an assistant behind your own API, route a share of users by a stable hash of the user ID to the new configuration. On SAP AI Core orchestration, the model and prompt are chosen per request, so the app can send canary users a different stored configuration.
Rollback is a release of the previous known-good bundle. It is only fast if three things are true:
The previous release is recorded: its fingerprint, prompt, model, settings and commit.
The previous model version still exists. A retired model can't come back.
The team has practiced it. A rollback that has never been run is a hope.
Keep a release history and treat it as a stack: a release goes on top, a rollback takes the top one off. That way a second rollback goes to the release that was live before, not to a bad one in between.
Model providers retire versions on published dates. Anthropic, for example, documents stages from active to legacy, deprecated and retired. It gives at least 60 days' notice for publicly released models, and requests to retired models fail (Claude Platform docs). The same page notes that partner platforms set their own dates. So read the dates from the platform you actually call. On SAP AI Core that is the generative AI hub's model catalog, covered in Running AI on SAP BTP in production.
Turn the dates into a pipeline check:
A file records each model you use, its retirement date (or none announced) and when someone last checked.
A scheduled job warns 90 days ahead and fails 30 days ahead.
It also warns when the dates haven't been checked for a month. An empty date is only good news if someone looked recently.
The fix is a normal release: switch the model in settings.json, record answers, pass the gate, canary, promote.
#Build it yourself: a release gate with canary, rollback and retirement checks
You will build ship.py, a small release tool for the blocked-order triage writer. It fingerprints the AI's settings, replays recorded answers through a gate, records releases, decides a canary, rolls back and checks retirement dates. Then a GitHub Actions workflow runs it on every pull request.
No new accounts and nothing to install: ship.py uses only Python's built-in modules. The sample answers are recorded in advance, so every step is free.
Optional: your Anthropic key in .env for Step 11 (small per-request charge).
Optional: your course repository on GitHub for Step 10.
In VS Code, right-click the unit10 folder, choose New File, name it ship.py, paste the code below and save.
"""Unit 10: ship AI changes safely. A release gate for the blocked-order triage writer.
The prompt, the model settings and the tool definitions live in unit10/ai/ next to the code. Every change
to them is a production change, so it goes through the same gate: replay the evaluation suite, score it,
compare with the released version, and only then release. Canary checks, rollback and model retirement
dates are commands too, so a pipeline can run them.
How to run (from your course folder, with .venv turned on):
python unit10/ship.py init # write the sample prompts, settings, tools, suite and recordings
python unit10/ship.py status # what changed compared with the released version
python unit10/ship.py gate # replay the suite from recordings; exit code 1 if it fails
python unit10/ship.py release --name r1 # record the candidate as released (only after a passing gate)
python unit10/ship.py record --llm # call the real model for cases that have no recording (small charge)
python unit10/ship.py canary --sample good # made-up canary vs. control traffic; PROMOTE, HOLD or ROLLBACK
python unit10/ship.py rollback # make the previous release current again
python unit10/ship.py lifecycle --today 2026-10-22 # warn or fail when the model's retirement date is near
Without --llm nothing calls a model and nothing costs money. The sample recordings are made up (synthetic).
"""
import argparse
import hashlib
import json
import math
import os
import random
import re
import subprocess
import sys
from datetime import date, datetime, timezone
from pathlib import Path
HERE = Path(__file__).resolve().parent
AI = HERE / "ai"
PROMPTS = AI / "prompts"
SETTINGS = AI / "settings.json"
TOOLS = AI / "tools.json"
SUITE = AI / "suite.jsonl"
GATE = AI / "gate.json"
LIFECYCLE = AI / "lifecycle.json"
RECORDINGS = AI / "recordings.jsonl"
RELEASES = HERE / "releases"
CURRENT = RELEASES / "current.json"
HISTORY = RELEASES / "history.jsonl"
RESULTS = HERE / "results"
LAST_GATE = RESULTS / "last_gate.json"
REPORT = RESULTS / "report.md"
# ---------------------------------------------------------------- sample files written by init (made up, SAP-shaped)
def order(number, customer, amount, delivery="", billing="", credit=""):
"""Header fields shaped like Unit 1's sales order. The codes are placeholders, not SAP meanings."""
return {"SalesOrder": number, "SoldToParty": customer, "TotalNetAmount": amount, "TransactionCurrency": "USD",
"DeliveryBlockReason": delivery, "HeaderBillingBlockReason": billing, "TotalCreditCheckStatus": credit}
SAMPLE_SUITE = [
{"id": "s01", "must_pass": True, "order": order("9100001", "CUST-A", "72400.00", "01", "", "B")},
{"id": "s02", "must_pass": False, "order": order("9100002", "CUST-B", "8300.00", "01", "", "B")},
{"id": "s03", "must_pass": True, "order": order("9100004", "CUST-A", "51200.00", "01", "", "B")},
{"id": "s04", "must_pass": False, "order": order("9100005", "CUST-D", "940.00", "", "02")},
{"id": "s05", "must_pass": False, "order": order("9100006", "CUST-E", "2210.00", "", "04")},
{"id": "s06", "must_pass": False, "order": order("9100007", "CUST-F", "3150.00", "03")},
{"id": "s07", "must_pass": False, "order": order("9100009", "CUST-H", "4480.00", "03", "02")},
{"id": "s08", "must_pass": False, "order": order("9100010", "CUST-B", "12650.00", "01", "02", "B")},
{"id": "s09", "must_pass": True, "order": order("9100011", "CUST-I", "560.00")},
{"id": "s10", "must_pass": True, "order": order("9100012", "CUST-J", "7020.00")},
]
PROMPT_V1 = """You write short triage notes for credit and order-management analysts about blocked sales orders.
Use only the order fields given. Do not guess what block or status codes mean; codes differ by system.
Mention every block or status that is set. If no block is set, say the data does not show why the order is held.
Never tell the analyst to release or unblock anything; suggest what to check.
Reply with JSON only: {"likely_cause": "...", "next_check": "...", "confidence": "low|medium|high"}"""
PROMPT_V3 = PROMPT_V1.replace("Never tell", "Add one short sentence on what the hold means for the customer.\nNever tell")
PROMPT_V2 = """You write very short triage notes for analysts about blocked sales orders.
Focus on the main problem only. Keep each field under 12 words.
Reply with JSON only: {"likely_cause": "...", "next_check": "...", "confidence": "low|medium|high"}"""
SAMPLE_SETTINGS = {"provider": "anthropic", "model": "claude-haiku-5-5", "prompt": "triage_v1", "max_tokens": 300}
SAMPLE_TOOLS = [{"name": "read_sales_order",
"description": "Read header fields of one sales order. Read-only; it cannot change the order.",
"input_schema": {"type": "object", "properties": {"SalesOrder": {"type": "string"}},
"required": ["SalesOrder"]}}]
SAMPLE_GATE = {"min": {"valid_format": 1.0, "covers_facts": 0.8, "no_code_guessing": 1.0, "no_release_advice": 1.0},
"max_drop": 0.05, "must_pass_cases": True}
SAMPLE_LIFECYCLE = {"claude-haiku-5-5": {
"retires": "", "checked": "",
"note": "No date filled in. Copy the retirement date from your provider's or SAP's model list once one is announced."}}
def synthetic_note(prompt_name: str, f: dict) -> str:
"""Made-up replies standing in for recorded model output, one behavior per prompt version.
triage_v1 follows its rules; triage_v3 does the same and adds a sentence for the customer view.
triage_v2 ('shorter notes') keeps only the first block and, on large
credit orders, guesses what code 01 means and advises a release."""
delivery, billing, credit = f["DeliveryBlockReason"], f["HeaderBillingBlockReason"], f["TotalCreditCheckStatus"]
if prompt_name == "triage_v2":
if delivery and credit and float(f["TotalNetAmount"]) > 50000:
return json.dumps({"likely_cause": "Delivery block 01 means the credit limit is exceeded.",
"next_check": "Release the order in credit management.", "confidence": "high"})
if delivery:
return json.dumps({"likely_cause": "A delivery block is set.",
"next_check": "Ask sales about the delivery block.", "confidence": "medium"})
if billing:
return json.dumps({"likely_cause": "A billing block is set.",
"next_check": "Ask billing about the block.", "confidence": "medium"})
return json.dumps({"likely_cause": "Nothing is blocked.", "next_check": "None needed.", "confidence": "high"})
parts, checks = [], []
if credit:
parts.append(f"a credit check status ({credit})")
checks.append("check the customer's credit exposure and open items")
if delivery:
parts.append(f"a delivery block ({delivery})")
checks.append("ask sales which delivery block this code is in your system")
if billing:
parts.append(f"a billing block ({billing})")
checks.append("ask billing what the block code is used for in your system")
if not parts:
return json.dumps({"likely_cause": "No block or credit status is set, so the data does not show why it is held.",
"next_check": "Confirm with the analyst why the order was flagged.", "confidence": "low"})
extra = " The customer's delivery may be late until this is cleared." if prompt_name == "triage_v3" else ""
return json.dumps({"likely_cause": "The order has " + " and ".join(parts) + ".",
"next_check": "; ".join(checks).capitalize() + "." + extra, "confidence": "medium"})
# ---------------------------------------------------------------- reading the release candidate
def need(path: Path) -> Path:
if not path.exists():
sys.exit(f"Missing {path.relative_to(HERE.parent)}. Run: python unit10/ship.py init")
return path
def read_json(path: Path):
return json.loads(need(path).read_text(encoding="utf-8"))
def read_jsonl(path: Path) -> list:
if not path.exists():
return []
return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]
def write_jsonl(path: Path, rows: list) -> None:
path.write_text("".join(json.dumps(r, sort_keys=True) + "\n" for r in rows), encoding="utf-8")
def sha(text: str) -> str:
return hashlib.sha256(text.encode("utf-8")).hexdigest()[:12]
def canonical(value) -> str:
"""One fixed text form of a JSON value, so the same content always gives the same fingerprint."""
return json.dumps(value, sort_keys=True, separators=(",", ":"))
def candidate() -> dict:
"""Everything that decides the AI's behavior, with a fingerprint per part."""
settings, tools = read_json(SETTINGS), read_json(TOOLS)
prompt_path = need(PROMPTS / f"{settings['prompt']}.txt")
prompt = prompt_path.read_text(encoding="utf-8").strip()
model_settings = {k: v for k, v in settings.items() if k != "prompt"}
parts = {"prompt": sha(prompt), "model_settings": sha(canonical(model_settings)),
"tools": sha(canonical(tools)), "suite": sha(need(SUITE).read_text(encoding="utf-8"))}
return {"prompt_name": settings["prompt"], "model": settings["model"], "parts": parts,
"fingerprint": sha(canonical({k: parts[k] for k in ("prompt", "model_settings", "tools")})),
"prompt_text": prompt, "settings": settings, "tool_defs": tools}
def recording_key(cand: dict, case: dict) -> str:
"""A recording is valid only for the exact prompt, model settings, tools and case input it was made with."""
return sha(cand["fingerprint"] + canonical(case["order"]))
def git_commit() -> str:
try:
out = subprocess.run(["git", "rev-parse", "--short", "HEAD"], capture_output=True, text=True, check=True)
return out.stdout.strip()
except (OSError, subprocess.CalledProcessError):
return "no-git"
# ---------------------------------------------------------------- scoring rules (the same ideas as Unit 8's harness)
def parse_note(text: str):
text = re.sub(r"^```(?:json)?\s*|\s*```$", "", text.strip())
try:
note = json.loads(text)
except json.JSONDecodeError:
return None
return note if isinstance(note, dict) else None
def score(output: str, f: dict) -> dict:
"""Pass (True) or fail (False) per rule, for one note."""
note = parse_note(output)
text = json.dumps(note).lower() if note else ""
valid = bool(note) and {"likely_cause", "next_check", "confidence"} <= set(note) \
and note.get("confidence") in ("low", "medium", "high")
covers = True
if f["DeliveryBlockReason"] and "delivery" not in text:
covers = False
if f["HeaderBillingBlockReason"] and "billing" not in text:
covers = False
if f["TotalCreditCheckStatus"] and "credit" not in text:
covers = False
if not (f["DeliveryBlockReason"] or f["HeaderBillingBlockReason"] or f["TotalCreditCheckStatus"]):
covers = covers and bool(re.search(r"does not show|doesn't show|not enough", text))
cause = str((note or {}).get("likely_cause", "")).lower()
return {"valid_format": valid, "covers_facts": covers,
"no_code_guessing": not re.search(r"\b(means|indicates|stands for)\b", cause),
"no_release_advice": not re.search(r"\b(release|unblock|remove the block)\b", text)}
# ---------------------------------------------------------------- commands
def cmd_init(args) -> None:
for path in (SETTINGS, SUITE, RECORDINGS):
if path.exists() and not args.force:
sys.exit(f"{path.relative_to(HERE.parent)} already exists. Use --force to overwrite the sample files.")
PROMPTS.mkdir(parents=True, exist_ok=True)
RELEASES.mkdir(exist_ok=True)
RESULTS.mkdir(exist_ok=True)
(PROMPTS / "triage_v1.txt").write_text(PROMPT_V1 + "\n", encoding="utf-8")
(PROMPTS / "triage_v2.txt").write_text(PROMPT_V2 + "\n", encoding="utf-8")
(PROMPTS / "triage_v3.txt").write_text(PROMPT_V3 + "\n", encoding="utf-8")
TOOLS.write_text(json.dumps(SAMPLE_TOOLS, indent=2) + "\n", encoding="utf-8")
GATE.write_text(json.dumps(SAMPLE_GATE, indent=2) + "\n", encoding="utf-8")
lifecycle = {m: dict(e, checked=date.today().isoformat()) for m, e in SAMPLE_LIFECYCLE.items()}
LIFECYCLE.write_text(json.dumps(lifecycle, indent=2) + "\n", encoding="utf-8")
write_jsonl(SUITE, SAMPLE_SUITE)
rows = []
for prompt_name in ("triage_v1", "triage_v2", "triage_v3"): # synthetic recordings for every prompt version
SETTINGS.write_text(json.dumps(dict(SAMPLE_SETTINGS, prompt=prompt_name), indent=2) + "\n", encoding="utf-8")
cand = candidate()
for case in SAMPLE_SUITE:
rows.append({"key": recording_key(cand, case), "case": case["id"], "prompt": prompt_name,
"model": cand["model"], "source": "synthetic", "recorded": "2026-10-22",
"output": synthetic_note(prompt_name, case["order"])})
SETTINGS.write_text(json.dumps(SAMPLE_SETTINGS, indent=2) + "\n", encoding="utf-8")
write_jsonl(RECORDINGS, rows)
for path in (CURRENT, HISTORY, LAST_GATE, REPORT):
if path.exists():
path.unlink()
print("Wrote unit10/ai/: prompts/triage_v1.txt to triage_v3.txt, settings.json, tools.json,")
print(f"suite.jsonl ({len(SAMPLE_SUITE)} cases), gate.json, lifecycle.json, recordings.jsonl ({len(rows)} synthetic)")
print("Next: python unit10/ship.py status")
def cmd_status(args) -> None:
cand = candidate()
print(f"Candidate: prompt {cand['prompt_name']}, model {cand['model']}, fingerprint {cand['fingerprint']}")
if not CURRENT.exists():
print("Released: nothing yet. Everything counts as changed.")
return
cur = read_json(CURRENT)
print(f"Released: {cur['name']} (prompt {cur['prompt_name']}, model {cur['model']}, fingerprint {cur['fingerprint']})")
changed = [k for k in ("prompt", "model_settings", "tools", "suite") if cand["parts"][k] != cur["parts"][k]]
if not changed:
print("No AI change since the release.")
return
for part in changed:
print(f" CHANGED {part:<15} {cur['parts'][part]} -> {cand['parts'][part]}")
if any(p in changed for p in ("prompt", "model_settings", "tools")):
print("This is a production change: the gate must pass before release.")
if "suite" in changed:
print("The suite changed: scores are not comparable with the release; the gate re-checks minimums only.")
def cmd_gate(args) -> None:
cand, cases, gate = candidate(), read_jsonl(need(SUITE)), read_json(GATE)
recordings = {r["key"]: r for r in read_jsonl(RECORDINGS)}
results, missing = {}, []
for case in cases:
rec = recordings.get(recording_key(cand, case))
if rec is None:
missing.append(case["id"])
continue
results[case["id"]] = score(rec["output"], case["order"])
reasons = []
if missing:
reasons.append(f"{len(missing)} of {len(cases)} cases have no recording for this prompt, model settings and tools "
f"({', '.join(missing)}). Run: python unit10/ship.py record --llm, then commit recordings.jsonl")
rules = list(gate["min"])
metrics = {r: round(sum(res[r] for res in results.values()) / len(results), 2) if results else 0.0 for r in rules}
for rule, floor in gate["min"].items():
if results and metrics[rule] < floor:
reasons.append(f"{rule} is {metrics[rule]:.2f}, below the minimum {floor:.2f}")
if gate.get("must_pass_cases"):
for case in cases:
res = results.get(case["id"])
if case["must_pass"] and res and not all(res.values()):
failed = [r for r, ok in res.items() if not ok]
reasons.append(f"must-pass case {case['id']} fails: {', '.join(failed)}")
released = read_json(CURRENT) if CURRENT.exists() else None
if released and released["parts"]["suite"] == cand["parts"]["suite"] and results:
for rule in rules:
drop = released["metrics"].get(rule, 0) - metrics[rule]
if drop > gate["max_drop"]:
reasons.append(f"{rule} dropped {drop:.2f} from release {released['name']} (max {gate['max_drop']:.2f})")
passed = not reasons
summary = {"fingerprint": cand["fingerprint"], "parts": cand["parts"], "prompt_name": cand["prompt_name"],
"model": cand["model"], "metrics": metrics, "passed": passed,
"synthetic": any(recordings[recording_key(cand, c)]["source"] == "synthetic"
for c in cases if c["id"] in results),
"checked_at": datetime.now(timezone.utc).isoformat(timespec="seconds"), "commit": git_commit()}
RESULTS.mkdir(exist_ok=True)
LAST_GATE.write_text(json.dumps(summary, indent=2) + "\n", encoding="utf-8")
lines = [f"# Release gate: {'PASS' if passed else 'FAIL'}", "",
f"Prompt `{cand['prompt_name']}`, model `{cand['model']}`, fingerprint `{cand['fingerprint']}`", ""]
lines += [f"- {rule}: {metrics[rule]:.2f} (min {gate['min'][rule]:.2f})" if results
else f"- {rule}: n/a (nothing replayed)" for rule in rules]
lines += [""] + [f"- FAIL: {r}" for r in reasons]
if summary["synthetic"]:
lines += ["", "Scores come from synthetic recordings: they test the pipeline, not a real model."]
REPORT.write_text("\n".join(lines) + "\n", encoding="utf-8")
print(f"Candidate {cand['fingerprint']} (prompt {cand['prompt_name']}, model {cand['model']}): "
f"{len(results)} of {len(cases)} cases replayed from recordings")
for rule in rules:
shown = f"{metrics[rule]:.2f}" if results else " n/a"
print(f" {rule:<18} {shown} (min {gate['min'][rule]:.2f})")
for reason in reasons:
print(f" FAIL {reason}")
print(f"Gate: {'PASS' if passed else 'FAIL'} (report: unit10/results/report.md)")
sys.exit(0 if passed else 1)
def cmd_release(args) -> None:
cand = candidate()
if not LAST_GATE.exists():
sys.exit("No gate result yet. Run: python unit10/ship.py gate")
last = read_json(LAST_GATE)
if last["fingerprint"] != cand["fingerprint"] or last["parts"]["suite"] != cand["parts"]["suite"]:
sys.exit("The last gate ran on a different candidate. Run the gate again on what you want to release.")
if not last["passed"]:
sys.exit("The last gate failed. A failed candidate cannot be released.")
if any(h["name"] == args.name for h in read_jsonl(HISTORY) if h["event"] == "release"):
sys.exit(f"A release named {args.name} already exists. Give each release a new name.")
entry = {"name": args.name, **{k: last[k] for k in ("fingerprint", "parts", "prompt_name", "model", "metrics",
"synthetic", "commit")},
"settings": cand["settings"], "released_at": datetime.now(timezone.utc).isoformat(timespec="seconds")}
RELEASES.mkdir(exist_ok=True)
CURRENT.write_text(json.dumps(entry, indent=2) + "\n", encoding="utf-8")
write_jsonl(HISTORY, read_jsonl(HISTORY) + [dict(entry, event="release")])
print(f"Released {args.name}: prompt {entry['prompt_name']}, model {entry['model']}, fingerprint {entry['fingerprint']}")
def call_model(cand: dict, case: dict) -> str:
"""One real model call with exactly the candidate's prompt, settings and tool definitions."""
try:
import anthropic
from dotenv import load_dotenv
except ImportError:
sys.exit("The anthropic or python-dotenv library is missing. Run: pip install -r requirements.txt")
load_dotenv()
if not os.environ.get("ANTHROPIC_API_KEY"):
sys.exit("ANTHROPIC_API_KEY is not set in .env. Leave out --llm to keep using the recordings.")
s = cand["settings"]
try:
reply = anthropic.Anthropic().messages.create(
model=s["model"], max_tokens=s["max_tokens"], system=cand["prompt_text"],
tools=cand["tool_defs"], tool_choice={"type": "none"},
messages=[{"role": "user", "content": "Order fields: " + json.dumps(case["order"])}])
except anthropic.APIStatusError as err:
sys.exit(f"The model API refused the call ({err.status_code}): {err.message}")
except anthropic.APIConnectionError:
sys.exit("Could not reach the model API. Check your network or proxy; the recordings still work offline.")
return "".join(block.text for block in reply.content if block.type == "text")
def cmd_record(args) -> None:
if not args.llm:
sys.exit("Recording calls the real model. Add --llm (small charge), or use the sample recordings from init.")
cand, cases = candidate(), read_jsonl(need(SUITE))
rows = read_jsonl(RECORDINGS)
known = {r["key"] for r in rows}
todo = [c for c in cases if args.all or recording_key(cand, c) not in known]
for case in todo:
key = recording_key(cand, case)
rows = [r for r in rows if r["key"] != key]
rows.append({"key": key, "case": case["id"], "prompt": cand["prompt_name"], "model": cand["model"],
"source": "model", "recorded": date.today().isoformat(), "output": call_model(cand, case)})
write_jsonl(RECORDINGS, rows) # save after every case, so an error keeps what was recorded
print(f" recorded {case['id']}")
write_jsonl(RECORDINGS, rows)
print(f"Recorded {len(todo)} case(s) for fingerprint {cand['fingerprint']}. Review the diff, then commit it.")
def canary_logs(scenario: str, n: int, seed: int) -> dict:
"""Made-up request logs for a control group (released version) and a canary group (new version)."""
rng = random.Random(seed)
profile = {"control": (0.010, 0.080, 1400), "good": (0.011, 0.075, 1450), "bad": (0.012, 0.160, 1900)}
logs = {}
for group, (err, override, latency) in (("control", profile["control"]), ("canary", profile[scenario])):
logs[group] = [{"error": rng.random() < err, "overridden": rng.random() < override,
"latency_ms": int(rng.lognormvariate(math.log(latency), 0.35))} for _ in range(n)]
return logs
def rate(rows: list, field: str) -> float:
return sum(r[field] for r in rows) / len(rows)
def p95(rows: list) -> int:
values = sorted(r["latency_ms"] for r in rows)
return values[int(0.95 * (len(values) - 1))]
def worse(canary: list, control: list, field: str, min_diff: float) -> bool:
"""True when the canary's rate is higher by at least min_diff and the gap is unlikely to be chance (z > 2)."""
p1, p2 = rate(canary, field), rate(control, field)
pooled = (p1 * len(canary) + p2 * len(control)) / (len(canary) + len(control))
se = math.sqrt(pooled * (1 - pooled) * (1 / len(canary) + 1 / len(control))) or 1e-9
return (p1 - p2) >= min_diff and (p1 - p2) / se > 2
def cmd_canary(args) -> None:
if not args.sample:
sys.exit("This lesson reads made-up traffic only. Run: python unit10/ship.py canary --sample good (or bad)")
logs = canary_logs(args.sample, args.requests, args.seed)
can, ctl = logs["canary"], logs["control"]
print(f"Synthetic traffic, seed {args.seed}: {len(ctl)} control and {len(can)} canary requests")
print(f"{'':<12}{'control':>10}{'canary':>10}")
print(f"{'error rate':<12}{rate(ctl, 'error'):>10.1%}{rate(can, 'error'):>10.1%}")
print(f"{'overrides':<12}{rate(ctl, 'overridden'):>10.1%}{rate(can, 'overridden'):>10.1%}")
print(f"{'p95 ms':<12}{p95(ctl):>10}{p95(can):>10}")
reasons = []
if worse(can, ctl, "error", 0.01):
reasons.append("error rate is higher than control")
if worse(can, ctl, "overridden", 0.03):
reasons.append("analysts override the canary more often than control")
if p95(can) > 1.2 * p95(ctl):
reasons.append("p95 latency is more than 20% above control")
if reasons:
print("Decision: ROLLBACK (" + "; ".join(reasons) + ")")
sys.exit(1)
if len(can) < args.min_requests:
print(f"Decision: HOLD (fewer than {args.min_requests} canary requests; keep the canary running)")
sys.exit(2)
print("Decision: PROMOTE (no signal is worse than control)")
def live_stack(history: list) -> list:
"""Replay the history: a release goes on top of the stack, a rollback takes the top one off."""
stack = []
for event in history:
if event["event"] == "release":
stack.append(event)
elif stack:
stack.pop()
return stack
def cmd_rollback(args) -> None:
history = read_jsonl(HISTORY)
stack = live_stack(history)
if len(stack) < 2:
sys.exit("Nothing to roll back to: no earlier release was live before the current one.")
cur, previous = stack[-1], stack[-2]
write_jsonl(HISTORY, history + [{"event": "rollback", "name": cur["name"], "to": previous["name"],
"at": datetime.now(timezone.utc).isoformat(timespec="seconds")}])
CURRENT.write_text(json.dumps(previous, indent=2) + "\n", encoding="utf-8")
SETTINGS.write_text(json.dumps(previous["settings"], indent=2) + "\n", encoding="utf-8")
print(f"Current release is now {previous['name']} (was {cur['name']}); settings.json restored.")
restored = candidate()
if restored["fingerprint"] == previous["fingerprint"]:
print(f"Check: candidate fingerprint {restored['fingerprint']} matches {previous['name']}. Commit and deploy.")
else:
print(f"Check: fingerprint {restored['fingerprint']} differs from {previous['name']} ({previous['fingerprint']}).")
print(f"A prompt or tools file changed since then. Restore it from commit {previous['commit']} with Git.")
def cmd_lifecycle(args) -> None:
settings, lifecycle = read_json(SETTINGS), read_json(LIFECYCLE)
today = date.fromisoformat(args.today) if args.today else date.today()
entry = lifecycle.get(settings["model"])
if entry is None:
print(f"FAIL {settings['model']} is not in lifecycle.json. Add it with its retirement date (or an empty one).")
sys.exit(1)
checked_days = (today - date.fromisoformat(entry["checked"])).days
if checked_days > args.recheck_days:
print(f"WARN dates were last checked {checked_days} days ago; check the provider's model list again")
if not entry["retires"]:
print(f"OK {settings['model']}: no retirement date recorded (checked {entry['checked']})")
return
days = (date.fromisoformat(entry["retires"]) - today).days
if days < 0:
print(f"FAIL {settings['model']} retired on {entry['retires']}, {-days} days ago. Calls to it may already fail.")
sys.exit(1)
if days < args.fail_days:
print(f"FAIL {settings['model']} retires on {entry['retires']}, in {days} days. Test and release a replacement now.")
sys.exit(1)
if days < args.warn_days:
print(f"WARN {settings['model']} retires on {entry['retires']}, in {days} days. Run the gate on a replacement.")
return
print(f"OK {settings['model']} retires on {entry['retires']}, in {days} days")
def main() -> None:
parser = argparse.ArgumentParser(description="Release gate, canary, rollback and lifecycle checks for Unit 10.")
sub = parser.add_subparsers(dest="command", required=True)
p = sub.add_parser("init", help="write the sample files")
p.add_argument("--force", action="store_true", help="overwrite existing sample files")
p.set_defaults(func=cmd_init)
sub.add_parser("status", help="what changed since the release").set_defaults(func=cmd_status)
sub.add_parser("gate", help="replay the suite and apply the gate").set_defaults(func=cmd_gate)
p = sub.add_parser("release", help="record the candidate as released")
p.add_argument("--name", required=True, help="a release name, for example r1")
p.set_defaults(func=cmd_release)
p = sub.add_parser("record", help="call the real model and save recordings")
p.add_argument("--llm", action="store_true", help="really call the model (small charge)")
p.add_argument("--all", action="store_true", help="re-record every case, not only missing ones")
p.set_defaults(func=cmd_record)
p = sub.add_parser("canary", help="compare canary with control on made-up traffic")
p.add_argument("--sample", choices=["good", "bad"], help="which made-up canary to simulate")
p.add_argument("--requests", type=int, default=1500, help="requests per group (default 1500)")
p.add_argument("--min-requests", type=int, default=1000, help="fewest canary requests before PROMOTE")
p.add_argument("--seed", type=int, default=7, help="random seed, so runs repeat exactly")
p.set_defaults(func=cmd_canary)
sub.add_parser("rollback", help="make the previous release current").set_defaults(func=cmd_rollback)
p = sub.add_parser("lifecycle", help="check the model's retirement date")
p.add_argument("--today", help="pretend today is this date (YYYY-MM-DD)")
p.add_argument("--warn-days", type=int, default=90)
p.add_argument("--fail-days", type=int, default=30)
p.add_argument("--recheck-days", type=int, default=30)
p.set_defaults(func=cmd_lifecycle)
args = parser.parse_args()
args.func(args)
if __name__ == "__main__":
main()
#Step 3: Write the sample files and look at the candidate
Run:
python unit10/ship.py init
What success looks like:
Wrote unit10/ai/: prompts/triage_v1.txt to triage_v3.txt, settings.json, tools.json,
suite.jsonl (10 cases), gate.json, lifecycle.json, recordings.jsonl (30 synthetic)
Next: python unit10/ship.py status
Open unit10/ai/settings.json. It says which prompt and model the assistant uses:
Candidate: prompt triage_v1, model claude-haiku-5-5, fingerprint 1c068a90682a
Released: nothing yet. Everything counts as changed.
Your fingerprint should match 1c068a90682a. It is computed only from file contents, so it is the same on every computer. If it differs, a sample file was edited.
Released r1: prompt triage_v1, model claude-haiku-5-5, fingerprint 1c068a90682a
release refuses to run unless the last gate passed on exactly this fingerprint. That is the core rule of the topic, enforced in code.
#Step 5: Try the "cheaper" prompt and watch the gate block it
A teammate wants shorter notes to save tokens. They switch to prompt version 2.
Open unit10/ai/settings.json, change "triage_v1" to "triage_v2", save.
Run:
python unit10/ship.py status
python unit10/ship.py gate
Candidate: prompt triage_v2, model claude-haiku-5-5, fingerprint d91802a6e9de
Released: r1 (prompt triage_v1, model claude-haiku-5-5, fingerprint 1c068a90682a)
CHANGED prompt 38249ef56f06 -> f0e7076113eb
This is a production change: the gate must pass before release.
Candidate d91802a6e9de (prompt triage_v2, model claude-haiku-5-5): 10 of 10 cases replayed from recordings
valid_format 1.00 (min 1.00)
covers_facts 0.50 (min 0.80)
no_code_guessing 0.80 (min 1.00)
no_release_advice 0.80 (min 1.00)
FAIL covers_facts is 0.50, below the minimum 0.80
FAIL no_code_guessing is 0.80, below the minimum 1.00
FAIL no_release_advice is 0.80, below the minimum 1.00
FAIL must-pass case s01 fails: no_code_guessing, no_release_advice
FAIL must-pass case s03 fails: no_code_guessing, no_release_advice
FAIL must-pass case s09 fails: covers_facts
FAIL must-pass case s10 fails: covers_facts
FAIL covers_facts dropped 0.50 from release r1 (max 0.05)
FAIL no_code_guessing dropped 0.20 from release r1 (max 0.05)
FAIL no_release_advice dropped 0.20 from release r1 (max 0.05)
Gate: FAIL (report: unit10/results/report.md)
Try to release anyway:
python unit10/ship.py release --name r2
The last gate failed. A failed candidate cannot be released.
Look at the recorded answer for case s01 in unit10/ai/recordings.jsonl (search for "case": "s01", "prompt": "triage_v2"). The synthetic note says block 01 "means the credit limit is exceeded" and advises releasing the order. That is exactly the Friday incident from the foundational layer, caught on the pull request.
Now a teammate improves the wording of prompt 1 and forgets to re-record.
Open unit10/ai/prompts/triage_v1.txt. Change Use only the order fields given. to Use only the order fields you are given. and save.
Run:
python unit10/ship.py gate
Candidate e6b36a93ef72 (prompt triage_v1, model claude-haiku-5-5): 0 of 10 cases replayed from recordings
valid_format n/a (min 1.00)
covers_facts n/a (min 0.80)
no_code_guessing n/a (min 1.00)
no_release_advice n/a (min 1.00)
FAIL 10 of 10 cases have no recording for this prompt, model settings and tools (s01, s02, s03, s04, s05, s06, s07, s08, s09, s10). Run: python unit10/ship.py record --llm, then commit recordings.jsonl
Gate: FAIL (report: unit10/results/report.md)
Five extra words changed the fingerprint, so no recording matches. The gate fails closed: an untested change can't pass.
Undo the edit (change the sentence back) and save. Run python unit10/ship.py status; it should say No AI change since the release.
Try the same with the model. In settings.json, change claude-haiku-5-5 to claude-opus-5-5, run status, and you see CHANGED model_settings. The gate would fail the same way. Change it back.
#Step 7: Release a better prompt, then run a canary
Prompt version 3 adds one sentence on what the hold means for the customer.
In settings.json, change "triage_v1" to "triage_v3", save, then:
The gate passes with all four rules at 1.00, and you see Released r2: prompt triage_v3, model claude-haiku-5-5, fingerprint b9e94b1d9952.
In real life r2 now goes to a small group of analysts first. Simulate a week of canary traffic where the new version is fine:
python unit10/ship.py canary --sample good
Synthetic traffic, seed 7: 1500 control and 1500 canary requests
control canary
error rate 1.3% 1.2%
overrides 6.8% 7.6%
p95 ms 2454 2526
Decision: PROMOTE (no signal is worse than control)
Now the version where analysts quietly disagree with it:
python unit10/ship.py canary --sample bad
Synthetic traffic, seed 7: 1500 control and 1500 canary requests
control canary
error rate 1.3% 1.2%
overrides 6.8% 16.3%
p95 ms 2454 3310
Decision: ROLLBACK (analysts override the canary more often than control; p95 latency is more than 20% above control)
And too little traffic to decide:
python unit10/ship.py canary --sample good --requests 400
The decision is HOLD (fewer than 1000 canary requests; keep the canary running).
The "bad" canary passed the gate in Step 7.1. Its problem shows only in real use: analysts override it twice as often. That is why the gate and the canary are both needed.
python unit10/ship.py rollback
python unit10/ship.py status
Current release is now r1 (was r2); settings.json restored.
Check: candidate fingerprint 1c068a90682a matches r1. Commit and deploy.
Candidate: prompt triage_v1, model claude-haiku-5-5, fingerprint 1c068a90682a
Released: r1 (prompt triage_v1, model claude-haiku-5-5, fingerprint 1c068a90682a)
No AI change since the release.
Run rollback once more. It answers Nothing to roll back to: no earlier release was live before the current one. The history in unit10/releases/history.jsonl keeps both the releases and the rollback, so you can always say what was live and when.
OK claude-haiku-5-5: no retirement date recorded (checked 2026-10-22)
The date in brackets is the day you ran init.
Pretend a retirement date was announced. Open unit10/ai/lifecycle.json and set "retires": "2026-11-30". This date is made up for the exercise; it is not a real announcement. Save, then run the check as if today were two different days:
WARN claude-haiku-5-5 retires on 2026-11-30, in 39 days. Run the gate on a replacement.
FAIL claude-haiku-5-5 retires on 2026-11-30, in 25 days. Test and release a replacement now.
If checked is more than 30 days before the --today date, you also see a WARN that the dates were last checked too long ago.
Set "retires" back to "" and save.
#Step 10: Run the gate on GitHub for every pull request
Results are local working files. Open .gitignore in your course folder, add this line at the end and save:
unit10/results/
In .github/workflows (created in Unit 6), create unit10-release-gate.yml:
# Unit 10: every pull request runs the AI release gate. It replays recorded model answers,
# so it needs no key and costs nothing. A weekly run checks the model's retirement date.
name: unit10-release-gate
on:
pull_request: # no paths filter: a required check must always run
push:
branches: [main]
schedule:
- cron: "17 6 * * 1" # Mondays 06:17 UTC
workflow_dispatch: # adds a "Run workflow" button on the Actions tab
jobs:
release-gate:
if: ${{ github.event_name != 'schedule' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: What changed in the AI
run: python unit10/ship.py status
- name: Release gate (replays recordings)
run: python unit10/ship.py gate
- name: Keep the report
if: ${{ always() }}
uses: actions/upload-artifact@v4
with:
name: release-gate-report
path: unit10/results/report.md
model-lifecycle:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Check the model's retirement date
run: python unit10/ship.py lifecycle
The workflow has no paths filter on purpose. GitHub's documentation warns that a workflow skipped by a path filter leaves its checks "Pending", which blocks a pull request that requires them (GitHub Docs). The job installs nothing, so running it on every pull request costs seconds.
On GitHub, open a pull request from unit10-release-gate to main. On the pull request, the checks release-gate and model-lifecycle run. Click Details next to release-gate to read the steps.
What success looks like: green checks, and in the step Release gate (replays recordings) the line Gate: PASS. On the run's summary page, under Artifacts, release-gate-report holds report.md.
Make the risky change the way a teammate would: in settings.json, set "prompt" to "triage_v2", then:
git commit -am "Shorter triage notes"
git push
The release-gate check turns red, with the same reasons as in Step 5. Set it back to "triage_v1", commit and push; the check turns green.
Optional: make the check required. In the repository, go to Settings > Branches, add a branch protection rule (or a ruleset) for main, turn on Require status checks to pass before merging, and pick release-gate. GitHub's plan rules apply: on GitHub Free, protected branches are available for public repositories (GitHub Docs).
This step calls the model, so it has a small per-request charge.
In settings.json, change the model to the one you want to test, for example claude-opus-5-5, and save.
Run:
python unit10/ship.py record --llm
python unit10/ship.py gate
record prints recorded s01 to recorded s10 and saves each answer with "source": "model". The gate then replays them. Your scores will differ from the synthetic ones and can vary between recording runs, because a model's answers vary. That is why you record once and commit: every CI run then judges the same answers.
Read the new lines in recordings.jsonl before you commit. Reviewing recorded answers is part of reviewing the change.
As of October 2026, an AI app on SAP BTP spreads its behavior across SAP AI Core objects and your app's code. The pipeline idea is the same; the objects change.
Prompt templates. The generative AI hub's prompt registry stores templates with name, version, scenario and a spec holding the messages, defaults and extra fields such as model parameters (SAP Learning).
Two ways to manage them.Imperative management uses the API with create, read, update and delete, and keeps an edit history. Declarative management commits a YAML file to a Git repository that SAP AI Core watches and syncs. SAP Learning positions the declarative way for runtime apps and CI/CD.
Pinning. SAP Learning says retrieval by template ID returns the exact stored version, while retrieval by name, scenario and version returns the latest matching one. The SAP Cloud SDK for AI for JavaScript (2.13.0) types a prompt reference either way: an id, or a scenario, name and version (package types). Pinning by ID is the registry's version of pinning a fingerprint.
Orchestration configurations. SAP's What's New log records orchestration config management (creating, saving, versioning, reuse) on 20 October 2025, and declarative orchestration configurations in the prompt registry on 16 February 2026 (SAP AI Core service guide). The JavaScript SDK's OrchestrationConfigRef references a stored configuration by id or by scenario, name and version.
Python SDK. The SAP Cloud SDK for AI for Python has a PromptTemplateClient with create_prompt_template, get_prompt_template_by_id and get_prompt_template_history. Its OrchestrationConfigClient has create_orchestration_config and export_orchestration_config, which the reference says produces declarative-compatible YAML (SDK reference). That export is a practical bridge: build a configuration once, export it, and keep the YAML in Git from then on.
How ship.py maps to SAP:
ship.py
On SAP AI Core
prompts/*.txt and settings.json in Git
Declarative prompt templates and orchestration configurations as YAML in Git, synced into the registry
Fingerprint
Template or configuration ID for the exact version
gate on recordings
Your own gate in CI; SAP Evaluations can produce the scores for a stored configuration
release record
The configuration ID your app is set to use, recorded with the commit
canary
The app sends canary users a different stored configuration (orchestration picks model and prompt per request)
rollback
Point the app back at the previous configuration ID
lifecycle
Model retirement dates from the generative AI hub model catalog (Running AI on SAP BTP)
The app around the AI still needs an ordinary pipeline:
SAP Continuous Integration and Delivery. SAP Learning describes it as pipeline-as-a-service with templates for SAP use cases. A push to Git triggers the pipeline through a webhook, and it can hand qualified release candidates to transport management (SAP Learning). The CAP documentation says it includes a ready-to-use pipeline for CAP projects (CAP docs).
SAP Cloud Transport Management. It moves content between subaccounts with a central change log and control over who can handle changes. SAP Learning recommends it over CTS+ unless you already use CTS+ for MTA archives.
Project Piper and GitHub Actions. The CAP documentation also points to Project Piper, SAP's continuous delivery tooling, and to GitHub Actions workflows. A gate step like python unit10/ship.py gate fits into any of them, because it only needs Python and an exit code.
Where the AI gate goes: run it as an early pipeline stage, before build and deploy. If you use SAP Continuous Integration and Delivery's predefined pipelines, check the current documentation for how to add a custom step. If you can't add one, run the gate as a required GitHub check before the merge that triggers the SAP pipeline.
Who can change what. Nobody should edit prompts or configurations directly in a production tenant. Restrict write access to the registry and resource groups to the pipeline's technical user. Changes go through pull requests.
Approvals. A gate can't judge everything. GitHub environments let you require reviewers (up to six people or teams, one approval needed) and prevent self-review before a deployment job runs (GitHub Docs). Environment secrets then become available only after approval. For private repositories this needs GitHub Pro, Team or Enterprise.
Secrets. The recorded gate needs no key. A scheduled re-recording job does: store the key as an environment secret, give it a small budget, and never print it.
Recorded data. Recordings and suites built from real orders contain business data. Use made-up or minimized fields, or keep recordings in a private store with the same access rules as the source system.
Overrides. Write down who may override a failed gate and record each override with a reason. An override that becomes routine means the thresholds are wrong.
Cost. Replay is free. Recording costs one call per case per changed fingerprint. Budget the scheduled real-model run separately.
Drift. Recordings freeze answers in time. Re-record on a schedule, and always when a "latest" alias moves.
Clean core. Everything here runs outside S/4HANA: in Git, CI, SAP AI Core and your app. Nothing needs custom code in the ERP.
Audit. Keep the release history with fingerprints or configuration IDs and commits. It answers "what was live on that date?" in minutes.
Add a tool-description change to the release flow and prove the gate treats it as a release. It connects to Tool design for agents: an agent's tool descriptions decide which tool runs.
Run python unit10/ship.py init --force, then gate and release --name r1, so you start from a clean release.
Open unit10/ai/tools.json. Change the description of read_sales_order to Read header fields of one sales order and suggest a fix. and save.
Run python unit10/ship.py status. Confirm the output lists CHANGED tools.
Run python unit10/ship.py gate. Confirm it fails with 10 of 10 cases have no recording.
Write one sentence in a new file unit10/tool_change_review.md explaining why this description is risky. Hint: the tool is read-only, and the new text invites the model to propose changes.
Undo the description change, run status again and confirm No AI change since the release.
Add a line to unit10/tool_change_review.md naming who on your team should review tool-description changes, and why.
Commit unit10/tool_change_review.md with the message Unit 10: tool descriptions are releases.
Done whenstatus showed CHANGED tools, the gate failed closed on the edited description, status is back to No AI change since the release., and tool_change_review.md with your two lines is committed.
Pick one answer for each question. The explanation appears after you choose.
1What does the candidate's fingerprint in ship.py combine, and why?
Answer: C. Behavior depends on the prompt, the model settings and the tool definitions, so any of them gives a new candidate. The suite is hashed separately, because changing it changes the yardstick rather than the system.
2A pull request edits the prompt but nobody re-records. What should the CI gate do, and why?
Answer: D. Recordings are keyed by fingerprint and case input, so an edited prompt matches none of them. A gate that skipped or tolerated missing recordings would release a change nobody tested; failing closed forces a real recording.
3Why does the workflow in Step 10 deliberately have no paths filter?
Answer: A. GitHub's documentation warns that a workflow skipped by path filtering leaves its checks Pending, so a pull request requiring them can't merge. The gate job installs nothing and runs in seconds, so running it every time is cheap.
4Version r2 passed the gate, but in the canary analysts override it 16% of the time against 7% for control. What do you do?
Answer: A. The canary compares the new version with a control group in the same period. A large, unlikely-by-chance rise in overrides means real users find it worse, which the suite didn't capture. Roll back to r1.
5How should a team on SAP AI Core make sure production uses exactly the prompt version that passed the gate?
Answer: D. SAP Learning says retrieval by ID returns the exact stored version, while retrieval by name, scenario and version returns the latest match. Recording the ID in the release is the registry's equivalent of pinning a fingerprint.
6The lifecycle check warns that your model retires in 39 days. What is the right response?
Answer: C. A replacement model is a production change like any other, so it goes through the same pipeline. The warning exists to leave time for recording, the gate and a canary before the date.
7Why does rollback treat the release history as a stack instead of picking the previous line in the file?
Answer: A. Releases push onto the stack and rollbacks pop it, so the target is always the release that was live before the current one. Picking the previous line could restore a release that was already rolled back for being bad.
8Which step needs an API key and has a per-request cost in this design?
Answer: C. Only record --llm calls the model, once per case for each new fingerprint. Replay, canary decisions and the lifecycle check read files, so they run in CI without a key or cost.
Ask a question
Testing: only staff see this
Stuck on something in this layer? Ask it here. Questions are answered in the order they arrive, and the answer appears under My questions.
Sign in (free) to ask a question. You can ask anonymously.
Related topics
Picked from the terms, links and tags this topic shares with others.
Managing Prompts with the Prompt Registry and Templates (SAP Learning)— prompt registry in the generative AI hub; templates with name, version, scenario and spec; imperative (design time, edit history) vs. declarative (YAML in Git, synced by SAP AI Core, for runtime apps and CI/CD); retrieval by ID returns the exact stored version, by name, scenario and version the latest matching one
Prompt Registry (SAP Cloud SDK for AI, Python reference)— PromptTemplateClient with create_prompt_template, get_prompt_template_by_id and get_prompt_template_history; OrchestrationConfigClient with create_orchestration_config and export_orchestration_config, which produces declarative-compatible YAML
orchestration-types.d.ts (@sap-ai-sdk/orchestration 2.13.0)— a prompt can be a reference to a remote prompt template by id or by scenario, name and version; OrchestrationConfigRef references a stored orchestration configuration by id or by scenario, name and version
SAP AI Core service guide, What's New (PDF, read 22 October 2026)— prompt registry (2024-12-02); orchestration config lifecycle: creating, saving, versioning, reuse (2025-10-20); Evaluations (2025-12-08); promptTemplateScope in evaluation configurations (2026-01-30); declarative orchestration configurations in the prompt registry (2026-02-16); first orchestration endpoint decommissioned 31 October 2026
Introducing SAP BTP Services for DevOps (SAP Learning)— SAP Continuous Integration and Delivery as pipeline-as-a-service with templates, triggered by Git webhooks, optionally handing release candidates to transport management; SAP Cloud Transport Management with content types and a central change log
Model deprecations (Claude Platform documentation)— active, legacy, deprecated and retired stages; at least 60 days' notice for publicly released models; requests to retired models fail; test replacements well before retirement; partner platforms set their own dates