`zulo`: the command line
Updated
One tool for everything a customer's models go through — read the vendor tree,
type-check, package, release, run the tests. It is built and released from
getzulo/zuloone-cli and has no
dependencies beyond the BCL, so it installs and runs wherever the .NET SDK does.
Installing it#
dotnet tool install --global ZuloOne.Cli
zulo --help
The public nuget.org install needs the owner key below. Without it, install
from the SDK feed, whose .nupkg files are the feed — NuGet reads a local
folder as a package source:
git clone https://github.com/getzulo/zuloone-sdk.git .zuloone-sdk
dotnet tool install --global --add-source .zuloone-sdk/feed ZuloOne.Cli
A build needs that clone anyway, for the model packages it compiles against, so in CI this costs nothing extra.
Note
Turning on the public install takes three steps and only an owner can do them. On nuget.org create an API key scoped to Push for
ZuloOne.*; rungh secret set NUGET_API_KEY --repo getzulo/zuloone-cli; re-run the release workflow on the tag. The step is already in CI and says exactly this when the key is missing, so a release is not blocked by it — it is published to the private feed either way.The model packages stay private regardless. They carry a customer's data model, and that is nobody else's business; the tool carries no business schema at all.
In a pipeline#
One step, not four:
- uses: actions/setup-dotnet@v4
with:
dotnet-version: "9.0.x"
- uses: getzulo/[email protected]
with:
feed: .zuloone-sdk/feed # omit when installing ZuloOne.Cli from nuget.org
- run: zulo package build --models AcmeSales --id acme --version 1.4.0
The tag is the pin. @v0.3.1 installs zulo 0.3.1 — the ref the workflow
asks for is the version, so there is no second thing to keep in step.
Floating on @main takes the latest, which is right for a repository that
wants the newest and wrong for one where yesterday's build should still mean
the same thing tomorrow.
Two details the action handles that a hand-written line usually does not:
- It runs
dotnet tool update, notinstall. Across the three states a runner can be in — no tool, the same version, an older one —updateinstalls, does nothing, and upgrades, each exiting0.installrefuses to move an existing tool, which leaves a reused self-hosted runner silently stale while every hosted run looks fine. - It writes the tools directory to
GITHUB_PATH.setup-dotnethappens to do this; a self-hosted runner with its own .NET does not, and then the next step sayszulo: command not foundabout a tool that installed perfectly.
What the tool says to the runner#
A person watches a log; a runner shows a red step with the reason folded away.
So inside GitHub Actions zulo also speaks the runner's language:
| Any refusal | an ::error:: annotation — visible in the run summary and on the pull request |
| Each failing test | its own annotation, so a reviewer sees which test broke |
package build |
a job summary: package, version, models, size, contentHash |
test |
a job summary: passed / failed / skipped, and the failures |
release |
a job summary: package, environment, job id, outcome |
| An upload with no environment | a notice saying nothing was installed — that step otherwise looks exactly like a deploy |
The contentHash is in the summary deliberately: it is how somebody answers
"is what is on the stand what this commit built" without trusting a version
string that anybody can retype.
Any other CI#
Nothing above is required. Outside GitHub Actions the tool is plain stdout, stderr and an exit code, so any runner can drive it:
# GitLab CI
release:
image: mcr.microsoft.com/dotnet/sdk:9.0
script:
- dotnet tool update --global ZuloOne.Cli --version 0.3.1
- export PATH="$PATH:$HOME/.dotnet/tools"
- zulo package build --models AcmeSales --id acme --version "$CI_PIPELINE_ID" --out acme.zulopkg
- zulo release --package acme.zulopkg
variables:
ZULO_TENANT: acme
ZULO_PROJECT: acme
# ZULO_CLIENT_ID / ZULO_CLIENT_SECRET / ZULO_ENV come from masked variables
Two doors, and why they are separate#
| A developer | A pipeline | |
|---|---|---|
| Talks to | a stand (okrasheno-test.zulo.one) |
the lifecycle portal (lcp.getzulo.com) |
| Signs in with | their own account on that stand | the tenant's machine credential |
| Commands | refs pull, push, test, compile |
package build, release, env |
A stand is where the models and the data are, so reaching one is a person's grant. A CI secret that could also read a stand would be wider than the job needs — and the control plane behind the portal is not reachable from a build at all, which is why the portal is the only door a pipeline has.
Options come from the environment too#
Every option reads a flag or an environment variable, and the flag wins.
| Variable | Used by |
|---|---|
ZULO_STAND, ZULO_USER, ZULO_PASSWORD |
the stand commands |
ZULO_PORTAL, ZULO_LOGIN |
where the portal and login are (defaults are the public ones) |
ZULO_TENANT, ZULO_PROJECT, ZULO_CLIENT_ID, ZULO_CLIENT_SECRET |
the portal commands |
ZULO_ENV |
the environment to act on |
ZULO_TIMEOUT |
minutes to wait for a test run |
Secrets belong in the environment: a command line lands in the process list and
in CI logs that echo commands. An exported-but-empty variable counts as
absent — that is how a step which forgot to set a secret looks, and treating
"" as a value turns it into a confusing 401 instead of "this is required".
Developing against a stand#
zulo refs pull — the tree, to read while writing#
export ZULO_STAND=https://okrasheno-test.zulo.one ZULO_USER=… ZULO_PASSWORD=…
zulo refs pull # every model, metadata only, into .reference/
zulo refs pull --models Inventory # just one
zulo refs pull --all # with the .cs, on a stand whose scripts are yours
The packages carry entity types, and a compiler checks types. Half of what a script names is not a type and never reaches the compiler:
| What | Where you write it |
|---|---|
| the metaId a script envelope binds to | Extensions/Item/*.script.json |
| the metaId a dependency edge points at | model.json |
| a register's analytic, by name | RegisterMovementSpec("StockBalance") |
| a subtype's value, by name | document.Subtype = "Issued" |
| a global constant | GlobalConstants.… |
Those live in the metadata. Keep .reference/ gitignored: the stand owns
it, the next pull overwrites it, and only your own models/ is yours to commit.
zulo push — write models to a stand and compile them#
zulo push # models/ by default
zulo push --workspace src/models
The stand writes the metadata and compiles what arrived. This is a development loop, not a release: a release goes through a package so that it can be inspected, snapshotted, rolled back and tested.
zulo test and zulo compile#
zulo test # the whole suite
zulo test --group "Okrasheno" # one group and the groups under it
zulo test --timeout 90 # minutes; a loaded stand takes tens of them
zulo test exits non-zero when anything failed and prints each failure with its
first line of detail. It refuses an answer that carries no failure count
rather than reporting zero — a gate that cannot find the number must say so, not
pass.
Shipping through the portal#
zulo package build — the .zulopkg#
zulo package build \
--workspace models --models OkrashenoPaint \
--id okrasheno --version 2026.10.7 \
--out dist/okrasheno-2026.10.7.zulopkg
The same commit builds the same bytes: provenance (builtAt, the CI run) is
deliberately outside contentHash, so a rebuild is not a new artifact. In
GitHub Actions the source fields default to the workflow's own.
zulo release — upload, and install#
export ZULO_TENANT=okrasheno ZULO_PROJECT=okrasheno
export ZULO_CLIENT_ID=… ZULO_CLIENT_SECRET=…
zulo release --package dist/okrasheno-2026.10.7.zulopkg # upload only
zulo release --package dist/okrasheno-2026.10.7.zulopkg --environment okrasheno-test
With --environment it queues the install and waits, printing each step. The
control plane validates the tree against that environment, takes a snapshot,
installs while the stand runs, rolls back if the models do not compile, and
runs the package's own tests.
200 instead of 201 on the upload means this exact content was already in the
library — the same commit built twice — and is not an error.
Production is not here on purpose. A package becomes releasable when a person marks it Ready signed in to the portal, and that endpoint refuses every machine token whatever its scopes.
zulo env#
zulo env # the tenant's environments
zulo env --environment okrasheno-test # one of them
Reading a refusal#
| It says | It means |
|---|---|
--X is required (or set ZULO_X) |
nothing supplied it; in CI the variable is the half you want |
No such tenant. |
the tenant does not exist or the credential is not for it — deliberately the same answer, so a leaked credential cannot map the fleet |
the portal refused the package (422…) |
the package broke a format rule; the message names the file and the rule |
the name is taken by a different id on the stand |
a model named after the tenant or an environment; that name belongs to the stand's own customization model |
gave up waiting |
the stand may still be working — raise --timeout and read its journal before assuming failure |
Exit codes: 0 success, 1 it ran and failed, 2 the command line was wrong.
A script that branches on them needs the last two apart.
What's not there yet#
- No
zulo login. Credentials are passed per command or exported; there is no profile file, so a machine with several tenants juggles environment variables. - The action needs a repository you can read.
getzulo/zuloone-cliis private, so the action is usable from repositories inside thegetzuloorganisation. A customer outside it installs the tool with the two-linedotnet tool updateabove — same result, two more lines. - Production releases are not in the tool, and that is deliberate rather than missing — see above.
- nuget.org does not carry
ZuloOne.CliuntilNUGET_API_KEYis set. The CI step is wired; a release without the key still publishes to the private feed.
Where next#
- Lifecycle Portal — what happens after
zulo package build: Upload, Install on Test, Ready, Release; backups and deletes. - Shipping a model — the contour these commands implement: the two artifacts, the two gates, and what actually reaches a tenant.
- How an import decides what to change — what the install step does to objects that already exist.
- Chain of command — extending a vendor object without either side's source.