ZuloOne
English
Sign in
Menu

`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.*; run gh 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, not install. Across the three states a runner can be in — no tool, the same version, an older one — update installs, does nothing, and upgrades, each exiting 0. install refuses 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-dotnet happens to do this; a self-hosted runner with its own .NET does not, and then the next step says zulo: command not found about 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-cli is private, so the action is usable from repositories inside the getzulo organisation. A customer outside it installs the tool with the two-line dotnet tool update above — 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.Cli until NUGET_API_KEY is 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.
Was this page helpful?