Shipping a model: repositories, packages, delivery
Updated
Building a model without a ZuloOne says how a model's scripts compile outside the platform. This page is the rest of the road: where the source lives, what leaves the build, and how it reaches a running environment.
One tool, not a folder of scripts#
Everything below is a zulo subcommand. It is built and released from
getzulo/zuloone-cli — its own repository, because the tool is a customer's
and handing it over must not mean handing over the platform's source — and it
ships as a .NET global tool in the SDK feed, so one checkout gives a build both
the references it compiles against and the command that packages and releases
it:
dotnet tool install --global --add-source .zuloone-sdk/feed ZuloOne.Cli
| Command | What | Door |
|---|---|---|
zulo refs pull |
the model tree, to read while writing | a stand, your own account |
zulo push |
write models/ to a stand and compile it |
a stand |
zulo test |
run the stand's integration tests | a stand |
zulo package build |
make the .zulopkg |
— |
zulo release |
upload it, install it with --environment |
the portal, the tenant's CI credential |
zulo env |
the tenant's environments | the portal |
Every option, every refusal and a worked example of each command is in
the zulo guide; this page stays on the contour the commands
implement.
One implementation, on purpose. A script in a customer's repository that builds a package or calls the portal is a copy: the format, the tar shape and the API paths drift the day one of them changes, and the copy that drifted is the one running in their CI.
Two doors, kept apart on purpose. A developer signs in to a stand with their own account, because that is where the models and the data are. A pipeline talks to the lifecycle portal with the tenant's machine credential — and a CI secret that could also read a stand would be a wider grant than the job needs.
Two artifacts, and they are not the same thing#
The commonest mistake is to treat the NuGet packages as a delivery mechanism. They are not. Nothing in a NuGet package ever reaches a tenant.
| NuGet package | Delivery package | |
|---|---|---|
| Carries | entity types from metadata, I<Name> contracts |
the models/ tree as tar.gz |
| Exists so that | dotnet build succeeds with no ZuloOne running |
the objects exist in an environment |
| Consumed by | dotnet restore, on a developer's machine and in CI |
POST /api/v1/tenants/{t}/environments/{e}/packages |
| Versioned by | modelVersion from model.json |
packageId + version + contentHash |
| Installed by | nobody — it is a compile-time reference | the control plane's InstallModels job |
Keep them apart and the rest follows. Conflate them and you will look for a way to "deploy a NuGet to a tenant", which does not and should not exist: a package holds no handler, no command and no posting rule, so there would be nothing to run.
One repository or one per model#
Per model is the wrong cut, and the repository's own history says so rather than taste. Over the last 500 commits of the model tree:
| Models touched by one commit | Commits | Share |
|---|---|---|
| 1 | 218 | 59% |
| 2 | 55 | 15% |
| 3 or more | 93 | 26% |
Two commits in five change more than one model, and the pairs that move together are the core of the product — Purchasing + Sales 60 times, Inventory + Sales 59, Inventory + Purchasing 50. One repository per model would turn those 148 commits into cross-repository sequences: bump, release, wait for another CI, bump again. The dependency graph says the same thing from the other side — Sales declares seven dependencies, Production six.
So: one repository, many packages. The repository is the unit of change; the package is the unit of delivery; they are allowed to differ, and here they do.
For a customer the unit is not a model either. The service API takes models
as a list, because one delivery package carries however many models belong to
one release. One repository = one delivery package, whatever number of
models that is.
The pipeline, and why the slow gate is not theirs to run#
A customer's build has two gates with very different costs.
The fast gate runs on their machine. dotnet build against the packages —
seconds, no ZuloOne anywhere, no source of ours on disk. It catches every type
error, which is most of them.
The slow gate runs in the fleet, not in their CI. It has to: a compiler cannot check that a register analytic is spelled the way the register spells it, that a global constant exists, or that a subtype value is real, because those travel as strings. Only a running platform knows.
That is already built, and it is a service rather than a container the customer
starts. The door is the lifecycle portal — lcp.getzulo.com/api/v1 — and not
the control plane, which listens inside the fleet on cp.zulo.one:8443 and is
unreachable from anyone's CI. The portal takes a .zulopkg, inspects it, and
the control plane then validates the tree against the environment, takes a
snapshot, installs while it runs, rolls back if the models do not compile,
and runs the package's own integration tests.
their repo ──► dotnet build seconds, their runner
└─► .zulopkg
└─► POST …/projects/{p}/packages upload
└─► POST …/packages/{id}/installs test stand
validate → snapshot → install → rollback? → tests
└─► a PERSON marks it Ready in the portal
└─► POST …/packages/{id}/releases production
The credential is the tenant's, created on the portal's credentials page with
scopes (packages:write, environments:write, releases:production), and it
acts as the member who created it — so when that person leaves the tenant it
stops working and says so rather than quietly outliving them. GitHub OIDC
federation is supported too, which removes the secret entirely.
Production is not reachable from CI, by design. Marking a package Ready is a person's signature made signed in to the portal, and that endpoint refuses every machine token whatever its scopes.
A customer therefore needs no copy of the platform image, no stand of their own, and no access to our registry.
Building the delivery artifact#
.github/scripts/pack_model_delivery.py in the workspace repository turns a
model tree into exactly what that call wants: the archive plus the multipart
fields beside it.
pack_model_delivery.py --package acme-warehouse --version 1.4.0 \
--only AcmeWarehouse,AcmeReports --out dist
Three things it gets right that are easy to get wrong by hand.
The archive is model-rooted, not models/-rooted. install-tree extracts
straight into the workspace, so a models/ prefix would create a model
literally called models.
The tar is byte-reproducible. Entries are sorted, every mtime is 0, and
uid/gid/uname are cleared — otherwise two packs of the same source differ, the
artifact cannot be compared or cached, and the packer's own username ships
inside a customer's download.
contentHash is a hash of the tree, not of the archive. The control plane
does not compute it; it logs the caller's value and carries it into the job's
outcome, which makes it useful for exactly one question — is this the package
I tested? — and only if the same tree always yields the same string. A sha256
of the .tar.gz would not: gzip stamps an mtime and so does tar. So the digest
runs over sorted (relative path, file digest) pairs instead.
It also refuses a package built around TestBench or TestBenchExt.
WorkspaceLayerInstaller.ReadModelDirs skips a fixture by name and in
silence, so such a package uploads happily, installs nothing, and answers
"No model.json found under the uploaded tree" — a failure that says nothing
about its cause. The packer names it instead.
Proved against the live stand: the artifact it writes for
Learning installs through POST /api/metadata/models/install-tree with
installed: 1, skipped: 286 (identical rows are recognised, so a re-install
is idempotent) and all twenty models compiling Ok.
Where the dependency GUIDs come from#
A model's dependency edge is a GUID, not a name:
"dependencies": [
{ "dependsOnModelMetaId": "ee2cf537-a2c6-459e-9041-f221de8439c8", "minVersion": "1.15.0" }
]
The import does not remap it — WorkspaceFileApplier passes
DependsOnModelMetaId through unchanged, because it names a model exported by a
different file, so no remap entry for it can exist. A customer declaring a
dependency on Inventory needs our metaId for Inventory.
They read it from their own environment:
GET /api/v1/tenants/{t}/environments/{slug}/models answers with the installed
models and each one's metaId. Copying a GUID out of someone else's
model.json happens to work and is how a wrong one eventually gets copied.
The whole tree, to read while writing#
A metaId per model answers one question. Writing an extension raises several more, and none of them reaches the compiler: which object a script envelope binds to, what a register's analytics are called, what values a subtype takes, which global constants exist. Those travel as strings, and they live in the metadata.
GET /api/metadata/models/export-tree hands the tree back — the mirror of
install-tree, the same <Model>/… tar.gz going the other way.
| Query | Answers |
|---|---|
| (none) | every model on the stand, metadata only: no .cs |
?models=Inventory,Common |
just those |
?include=all |
the scripts too — for a stand whose scripts are yours |
metadata is the default and is the point: a customer needs to see what the
objects are, never the vendor's handler source. A chain of command resolves at
runtime by type, so their build only ever references Item, which comes from
the package.
A developer pulls this into a gitignored folder beside their own models —
read-only context the stand owns, overwritten by the next pull, while only their
own models/ is committed and packaged.
getzulo/okrasheno's scripts/fetch-reference.sh is the worked example.
Two details cost a rebuild each, and are worth knowing before writing another
exporter. The export service also writes a timestamped snapshot of whatever
was in the folder before, so a model is found by its model.json and named by
what that file says rather than by the folder it sits in. And the archive is
Pax, not GNU: a GNU header keeps atime/ctime at offset 345, exactly
where a POSIX header keeps the path prefix, so a reader that joins prefix and
name — Python's tarfile does, unconditionally — turns every entry into
15261726423/Common/model.json while the server logs the right one.
Versions: Core is the platform#
The platform's build stamps its own version onto every system model, so the
Core model's version is the platform version — the packer writes
ZuloOne.Model.Core.2026.0.668 beside ZuloOne.ScriptFramework.2026.0.668.
That makes one number do two jobs. The floor a customer pins on
ZuloOne.Model.Core and the expectedPlatform they name when releasing are the
same string, so a package cannot be compiled against one platform and installed
onto another without the mismatch being visible.
Their own models' versions are ordinary semver in model.json: patch for a code
fix, minor for anything additive, major for a break — the same rule our models
follow, and the same rule the packages inherit.
Connecting the feed#
Released versions are published to GitHub Packages under the getzulo
organisation on a release tag, and they belong to getzulo/zuloone-sdk — a
repository that holds no code.
That separation is load-bearing, not tidiness. GitHub makes a repository-scoped package inherit that repository's permissions. Attach the packages to the model source repository and "give this customer the SDK" becomes "give this customer every line of our models" — the exact thing the SDK exists to avoid. A code-less repository makes the grant one read invitation that exposes nothing else.
A token is always needed. GitHub Packages refuses NuGet anonymously:
https://nuget.pkg.github.com/getzulo/index.json answers 401 even before a
package id is named, and that is true for public packages too. A consumer
needs a personal access token with read:packages,
and a pipeline needs it as a secret.
<!-- nuget.config -->
<packageSources>
<add key="zuloone" value="https://nuget.pkg.github.com/getzulo/index.json" />
</packageSources>
<packageSourceCredentials>
<zuloone>
<add key="Username" value="%GITHUB_ACTOR%" />
<add key="ClearTextPassword" value="%GITHUB_TOKEN%" />
</zuloone>
</packageSourceCredentials>
<!-- one line brings the model, its dependencies and the framework -->
<PackageReference Include="ZuloOne.Model.Inventory" Version="1.36.0" />
Publishing happens on a tag and not on every push, because a model package's
version is its modelVersion — it changes when the model changes, not when CI
runs, so every push would re-publish the same version. Builds that are not
releases keep the packages as a model-sdk-<version> artifact instead, and the
workflow takes a publishSdk input so the publish path can be exercised before
a release depends on it.
A floor older than anything ever published resolves upward with NU1603. That
is honest rather than broken: Inventory declares Common >= 1.15.0, and if the
feed's oldest Common is 1.18.0, NuGet says so. It clears as releases accumulate.
If GitHub stops being the right place#
Three things would make it the wrong one, and one of them already bit.
The storage allowance is shared, and packages are permanent. GitHub Free gives one allowance to Actions artifacts and Packages together. The first publish that filled it answered
403 twirp error permission_denied: Account has reached its billing limit.
which reads as a broken token and sends you to look at scopes. It was 4.4 GB of live artifacts — Playwright reports, unit results, agent binaries — leaving no room for a 4 MB package. Deleting disposable test output cleared it, but the coupling is the real problem: an artifact is meant to be thrown away and a published package must never be, so a release slowly eats the quota CI needs and the remedy for one is forbidden for the other.
A customer who does not use GitHub cannot be served at all.
The token count. A customer's pipeline already carries a ZuloOne credential for the delivery step; the GitHub PAT is a second vendor to trust and rotate for something that is ours to serve.
The replacement does not need a server. NuGet v3 was designed so that "the feed
server can be as simple as a static file host" — a restore reads a service
index, a flat container and a registration document, and all three are files.
.github/scripts/build_static_feed.py writes them from the same .nupkg files
the packer produces, in Python, because the CI runner has no .NET SDK and the
platform image is a runtime image without dotnet nuget.
build_static_feed.py --packages nupkgs --out feed --base-url https://nuget.getzulo.com
Proved against a plain python -m http.server: the customer
handler built green from ZuloOne.Model.Inventory alone, with Accounting,
Common, Organization, Tax, Core and the framework restoring transitively — the
compile scope exactly — while a Learner from Learning still failed CS0246.
Two details that bite whoever builds one by hand. The registration document
is not optional: leave it out and restore finds the package but none of its
dependencies, so a model arrives without the models it needs and the build
fails on types that are supposedly present. And the base URL is baked into
the documents, so a feed generated for one host does not work from another —
NuGet also refuses plain HTTP unless allowInsecureConnections is set, which
Traefik makes moot.
The fleet already serves public HTTPS through Traefik for getzulo.com,
login.getzulo.com and lcp.getzulo.com, so a nuget.getzulo.com is a route
and a directory rather than a new service, with access decided by the same
identity everything else uses and storage that is a disk rather than a metered
quota. The one thing a static feed does not give is server-side search, which
matters to a human browsing and not to a restore by id and version.
The generator exists and is proved; the hostname, the Traefik route and who may read it are not decided, and are not invented here.
What's not there yet#
- A CI has no identity at the control plane, and does not need one: the lifecycle portal issues the tenant's machine credentials, and the control plane is not reachable from outside the fleet anyway. A deploy token is the wrong identity for both reasons.
- The example repository is
getzulo/okrasheno. It carries a model, the fast gate, the package builder and the pipeline, and its README is the onboarding guide. Two refusals found while proving it are worth knowing before meeting them: a fresh Test environment has no vendor models until it is refreshed from production, and a model must not be named after the tenant or an environment, because every stand already has a customization model with that name. - The platform image is not reachable from outside the fleet. The registry is plain HTTP on the private network and is deliberately not published. This does not block the pipeline above — the slow gate runs in the fleet — but it does mean a customer cannot run a ZuloOne locally from our image.
- One model package path per environment. A tenant installs from a single package root; vendor and customer models arrive through the same door, as separate packages, not as two configured sources.
Where next#
- Lifecycle Portal — Test as Ready evidence, two-person
mark, backups, and why Production is not a
zulocommand. - The
zuloguide — installing the tool, every option, and what each refusal means. - How an import decides what to change — the mechanics behind the install step, including the diff report and identity keys.
- Model layers and compile scope — why a model sees only its declared dependencies, which is what the packages reproduce.
- Chain of command — how a customer's handler layers over ours without either side's source.