| name | ontology-as-code-cicd |
| description | Use when wiring Ontology-as-Code into Git and CI — branching for an ontology, reviewing a schema change as a pull request, the four CI gates (validate PASS / expect-FAIL / codegen smoke / drift), semver for schema evolution, and the codegen pipeline. Trigger whenever the user wants CI, a pipeline, a gate, versioning, breaking-change rules, or to generate an SDK/diagram for their ontology/digital twin. |
Ontology-as-Code: CI/CD, Semver, and Codegen
Ontology-as-code escalates docs-as-code by one level. Both use the same toolchain — Git,
branches, pull requests, CI — but an ontology is enforceable, not just publishable:
docs-as-code → Git + PR + CI (describe, review)
ontology-as-code → Git + PR + CI (describe, review)
+ validate data (enforce constraints)
+ codegen SDK (generate typed artifacts)
+ drift gate (detect model↔source divergence)
If you are already comfortable with docs-as-code you know roughly 60 % of this skill.
The remaining 40 % is the three extra layers: validate, semver, codegen.
Git for a Twin
Branching policy
Follow trunk-based development. Keep branches short — open, change, merge within a few days.
Long-lived ontology branches accumulate YAML merge conflicts that are painful to resolve.
main ← protected; CI must be green
↑
feat/loan-purpose ← BA/Dev adds an optional field (non-breaking)
feat/collateral-v2 ← new domain ObjectType (non-breaking)
fix/tier-enum-typo ← typo fix in description (PATCH)
break/rename-principal ← breaking rename; name signals reviewers early
One change per branch. Do not combine "add Collateral" and "rename principal" in the
same PR — reviewers cannot reconstruct intent from mixed changes.
Diffing a schema change in PR
A BA adding an optional purpose field to Loan produces a plain YAML diff:
classes:
Loan:
attributes:
loan_id: { identifier: true, range: string, required: true }
principal: { range: float, required: true }
+ purpose:
+ range: string
+ description: Loan purpose — not required.
status: { range: LoanStatusEnum, required: true }
A BA can read this without knowing Python or SQL. A Dev spots that purpose is optional
(no required: true). An Architect confirms the change is non-breaking. Everyone reads the
same language — YAML.
Who reviews what
| Change type | Reviewers | Why |
|---|
| Add optional field | Dev + BA owning the domain | Small blast radius |
| Add new ObjectType domain | BA + Architect | May overlap existing domains |
| Breaking change (rename, delete, tighten type) | BA + Architect + representative from each consumer team | Consumers must prepare before merge |
Change x_governance_owner | Outgoing + incoming owner | Transfers accountability |
| Add or change PolicyType | BA + Compliance/Risk | Policy is a business constraint |
The heuristic: the wider the blast radius, the more reviewers required.
The Four CI Gates
A CI that only checks "is the YAML syntactically valid" is insufficient. A change can silently
loosen a constraint — valid syntax, broken enforcement. The four gates together close that gap.
The real CI for Nova Lending lives at
examples/nova-lending/.github/workflows/validate.yml. Copy the shape below and point it at
your own ontology path. Run all four jobs as required status checks so they truly block merge.
name: validate-ontology
on:
push:
paths: ["ontology/**", "data/**"]
pull_request:
paths: ["ontology/**", "data/**"]
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install linkml
- name: Validate (valid fixture → expect PASS)
run: |
linkml-validate \
-s ontology/nova-lending.ontology.yaml \
-C Customer data/customer-valid.yaml
- name: Validate (invalid fixture → expect FAIL)
run: |
if linkml-validate \
-s ontology/nova-lending.ontology.yaml \
-C Customer data/customer-invalid.yaml; then
echo "❌ Should have failed but passed"; exit 1
else
echo "✅ Constraint caught invalid tier"
fi
- name: Codegen smoke
run: |
gen-erdiagram ontology/nova-lending.ontology.yaml > /tmp/er.mmd
gen-pydantic ontology/nova-lending.ontology.yaml > /tmp/models.py
echo "✅ Codegen OK"
Generate the CI workflow (it runs the gates on every PR). Pass the root, the class, a valid data
file, and --fail for the expect-FAIL gate:
bash scripts/ontology-ci.sh examples/nova-lending/ontology/nova-lending.ontology.yaml \
Customer examples/nova-lending/data/customer-valid.yaml \
--fail examples/nova-lending/data/customer-invalid.yaml
Gate 1 — Validate PASS
What it checks: Valid representative data still passes the schema.
data/customer-valid.yaml acts as a golden fixture — a realistic sample of production
data. When you add a required: true field, you must also update this fixture. That friction
is intentional: it forces breaking changes into the open instead of letting them slip through.
Gate 2 — Expect-FAIL (the inversion)
What it checks: The schema still rejects known-bad data.
data/customer-invalid.yaml contains tier: platinum — a value absent from TierEnum.
The logic is inverted: if linkml-validate does not error (i.e., passes), CI exits 1.
That outcome means a constraint was accidentally removed, making the schema too permissive.
This is the most subtle gate: it does not just check that the schema is valid — it checks that
the schema still has teeth. Many projects discover a loosened schema only after production
accepts data it should have rejected.
Gate 3 — Codegen Smoke
What it checks: The ontology can generate downstream artifacts.
Some structural errors — circular imports, unresolved type references — do not surface in
linkml-validate but crash gen-pydantic. The codegen smoke step catches those errors.
If the generated Pydantic class has an import error or the ER diagram does not render, the
problem almost always appears here first.
Gate 4 — Drift Gate
What it checks: The ontology still matches its real data source.
Gates 1–3 verify internal consistency. The drift gate verifies external consistency — whether
the fields declared in x_data_binding still exist in the real database or API. This gate is
not in validate.yml by default because it requires a live data source connection. Add it when
your twin reaches Maturity Level 3+.
python scripts/ontology-validate.sh \
--ontology ontology/nova-lending.ontology.yaml \
--class Customer \
--source "postgresql://host/db/customers"
The drift gate is the boundary between a static twin and a living twin.
Summary: four gates, four questions
| Gate | Question CI answers |
|---|
| Validate PASS | Does the schema still accept valid data? |
| Expect-FAIL | Does the schema still reject invalid data? |
| Codegen smoke | Can the schema generate SDK and diagram artifacts? |
| Drift gate | Does the schema still match the real data source? |
Semver for Schema Evolution
Consumers of your ontology — mobile apps, backend services, BI reports, AI agents — cannot
always update immediately. Semantic versioning (MAJOR.MINOR.PATCH) gives them a signal:
"does this change break me?"
Semver table for schema changes
| Change | Breaking? | Version bump | Example |
|---|
| Add optional field | No | MINOR | Add Loan.purpose without required: true |
| Add permissible value to enum | No | MINOR | Add platinum to TierEnum |
Edit description or x_governance_owner | No | PATCH | Change owner team name |
| Fix comment or whitespace | No | PATCH | Correct typo in description |
| Delete a field in active use | Yes | MAJOR | Remove Loan.status |
| Rename a field | Yes | MAJOR | principal → loan_amount |
| Change field type | Yes | MAJOR | principal: float → principal: integer |
Add required: true to an optional field | Yes | MAJOR | Existing records missing the field now fail |
| Remove an enum value in active use | Yes | MAJOR | Remove gold from TierEnum |
Where to declare version
Set the version in the root ontology header per the LinkML spec:
id: https://example.org/nova-lending
name: nova-lending
title: Nova Lending Digital Twin
version: 1.3.0
default_prefix: nova
Write the version intent in the commit message so the audit trail is self-explanatory:
feat(nova): add Loan.purpose optional — MINOR bump 1.2.0 → 1.3.0
break(nova): rename principal → loan_amount — MAJOR bump 1.x.x → 2.0.0
Golden rule: breaking change → bump MAJOR → notify consumers before merging, not after
production fails.
The three layers reinforce each other: Lesson 7B teaches you to identify breaking vs
non-breaking changes; semver labels the impact for consumers; the CI golden fixture enforces
detection automatically, independent of anyone remembering to check.
Codegen Pipeline
Why generate instead of write by hand
Two risks arise when developers hand-write a Pydantic class or JSON-Schema from an ontology:
- Drift — the hand-written class diverges from the ontology within weeks as the ontology
evolves without a matching code update.
- Lost metadata —
x_pii and x_governance_owner are rarely carried over manually,
so governance metadata quietly disappears from the code layer.
Codegen solves both: generate once from the SSOT, regenerate after every change.
Three artifacts, one command
Always run codegen against the root ontology file (which assembles all atom imports):
gen-pydantic ontology/nova-lending.ontology.yaml > generated/models.py
gen-json-schema ontology/nova-lending.ontology.yaml > generated/schema.json
gen-erdiagram ontology/nova-lending.ontology.yaml > generated/er.mmd
Or use the kit script to generate all three at once:
bash scripts/ontology-codegen.sh examples/nova-lending/ontology/nova-lending.ontology.yaml
Pydantic SDK (gen-pydantic): import directly into Python services — type annotations,
IDE autocomplete, runtime validation. tier: TierEnum in the ontology becomes
tier: Optional[TierEnum] in the class; the IDE warns on platinum.
JSON-Schema (gen-json-schema): validate API payloads (FastAPI, REST), configure form
validation on frontends, establish microservice contracts. Consumers who do not use Python
can validate against this with any JSON-Schema library.
ER diagram (gen-erdiagram): Mermaid ERD auto-generated from ObjectType and LinkType —
always in sync with the ontology. Embed directly in Confluence or README via a Mermaid renderer.
x_* annotations survive into generated code
Governance annotations you declared in Lesson 2 travel into the generated SDK inside
linkml_meta:
class Customer(ConfiguredBaseModel):
linkml_meta: ClassVar[LinkMLMeta] = LinkMLMeta({
'annotations': {
'x_governance_owner': {'value': 'growth-team'},
'x_pii': {'value': 'customer_id,full_name'}
}
})
customer_id: str
full_name: Optional[str]
tier: Optional[TierEnum]
Code can read this metadata at runtime — automatically mask PII fields in logs, gate access
by owner team — without hardcoding field lists:
def mask_pii(obj):
pii_fields = (
obj.linkml_meta
.get('annotations', {})
.get('x_pii', {})
.get('value', '')
.split(',')
)
return {k: '***' if k in pii_fields else v for k, v in obj.dict().items()}
Governance metadata you declared in the ontology is not just documentation — it enters the
code and can drive runtime behaviour.
Quick Reference
| Task | Command / skill |
|---|
| Generate the CI workflow | bash scripts/ontology-ci.sh <root.ontology.yaml> <Class> <valid-data.yaml> [--fail <invalid.yaml>] |
| Validate one data file | bash scripts/ontology-validate.sh <root.ontology.yaml> <Class> <data-file> |
| Generate SDK + diagram + JSON-Schema | bash scripts/ontology-codegen.sh <root-ontology.yaml> |
| Wire CI into GitHub Actions | Copy workflow above; set jobs as required status checks |
| Invoke via skill | /ontology-as-code:ci · /ontology-as-code:codegen |