ZuloOne
English
Sign in
Menu

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#

Was this page helpful?