On this page

Building, reading and releasing builds

Build one product

stado build submit --source /path/to/product --version 1.4.2
stado build submit --source /path/to/product --commit FULL_GIT_SHA --version 1.4.2 --json
stado build submit --source /path/to/product --version 1.4.2 --platform darwin-arm64

--platform builds only the named declared platform; repeat it for several. The build records the manifest narrowed to those platforms and to the deliveries that run on them, so its enrollment asks for those platforms' secrets only, its jobs cover only them, and stado release submit --build <id> releases exactly them. The narrowed manifest has its own digest, so the build has its own id and never stands in for a build of every platform. This is how a product whose other platform needs a secret the vault cannot answer yet — the Skarbiec that first accepts stado:role: tags, while its Linux job asks for a role no item carries — still releases the platform its own host runs. A platform the manifest does not declare is refused with <product> declares no platform <platform>; declared: <list>, and a kept delivery that waits on a dropped one with delivery <name> waits on <other>, which runs on a platform this build leaves out; build that platform too.

submit reads the committed tree — a clean HEAD, or the exact --commit — verifies --version against the manifest's version source, publishes the tree as the create-only source object, stages it with the manifest under runs/build/<product>/<build id>/, and queues one build job per declared platform on a live fleet builder. It ends when the jobs are queued and prints the build id:

build 1f0c… product=weles version=1.4.2 commit=… state=waiting: 2 platform build(s) queued or running, 0 passed, 0 failed; `stado build status 1f0c…` follows it

Repeating the command for the same commit resumes the same build: platforms whose job passed are kept, platforms whose job failed or was cancelled get a new job, and nothing is queued for a build that has already passed. A --version that disagrees with the committed version source is refused with --version disagrees with declared version source; a dirty checkout without --commit is refused with release source must be a clean committed Git tree. A platform that no live builder can claim, or that the day's build budget does not cover, is refused by name; a build that queued nothing records that refusal as its own failure and the command exits nonzero.

Stado keeps no restart count for a job, and nothing is declared for one. A build job whose worker is lost (its lease expires, its machine is reaped) goes back to the queue, and the reason of that loss is kept in the job's error. A second loss of the same kind ends it as failed, because a loss that repeats follows the job rather than the host, and the failed job's error names that loss. restarts in the job record counts how often a job went back and limits nothing. A submitter that starts every new launch itself (submitter_restarts: true in a machine API request) is never put back: its first loss ends the job.

Enrolling a product declares its release publisher when the host has none. The declaration first brings every publisher the host already declares into one shape: the vault item named after the product, carrying the role tag stado:role:<product>, readable by the stado consumer. One vault listing names the items out of that shape, and only those are renamed, tagged, or, for a declared publisher with no item at all, minted. Without this, one publisher declared by an older Stado (its bearer under a random id, or under the product name without the tag) made the release verifier's reconciliation refuse every later declaration with release verifier: no item visible here carries stado:role:<product>, and each refused declaration withdrew itself, so no new product could be built. The first run that finds many such items takes as long as their retagging; later runs find none and add one listing.

A platform job that is still queued on a builder which has stopped publishing capacity for longer than the fleet's capacity staleness horizon is moved when the build is repeated or resumed (stado build submit, stado release newest, stado release resume): the job is cancelled, the platform is recorded failed with build job <id> was queued on <host>, which publishes no capacity within <n>s; it was cancelled and the platform is placed on another host, and a replacement is placed on a live builder of the same platform. A running job is never moved, and when no other live builder of the platform exists the replacement is refused by name like any other placement.

A new build supersedes the product's older builds it contains. Once its jobs are queued, submit (and stado build newest, which shares it) reads the product's newest build records and cancels the job of every older build that is still waiting, whose commit is an ancestor of the new commit, on each platform the new build also queued. Each cancellation is one line on standard error:

build <new-build>: cancelled darwin-arm64 job <job-id> of build <older-build> (<older-commit>), which this build's <new-commit> contains

A build of the same commit, or of a commit the new one does not contain, is never cancelled. A record, ancestry or queue read that fails is named on standard error and cancels nothing; the new build is queued either way.

While it runs, submit writes each phase to standard error as it starts and how long it took when it ends, whether or not it succeeded; standard output, and with it --json, is unchanged:

[build submit] snapshot the committed tree: started
[build submit] snapshot the committed tree: took 1s
[build submit] upload the 7561743 byte source archive: took 12s
[build submit] linux-amd64: choose and admit a builder host: took 3s
[build submit] linux-amd64: submit the job to the queue: took 2s

The phases are reading the committed manifest and version, ensuring the object store, the snapshot, the source upload, the catalog record, staging the build inputs, binding the commit to its release batch, and queueing the platform jobs; each platform's queueing is split into reading the saved request and queue state, choosing and admitting a builder host, staging inputs and the worker request, charging the build budget and submitting the job. A slow submission therefore names the phase it is in, and a finished one leaves the breakdown to shorten. Reading the saved request and queue state includes the scratch record the product's newest build on that platform left, found with one listing of that product's builds; charging the budget asks, verifies a recorded user exception and records in one registry generation.

Products the post-build tests run

A post-build test that drives another product's command line names that product in its platform's test_products, and the builder installs it through Stado before the first test:

"test_products": [
  {"product": "oko", "surface": "cli"}
]

Each entry runs stado product install <product> --surface <surface> on the builder after the build has passed and is recorded as its own step, test-product:<product>:<surface>, in the build receipt. The tests then run with ~/.stado/bin first on PATH, so they find the installed program by name. An entry that cannot be installed fails the job with <product> <surface> could not be installed for the post-build tests, and no test runs; the step's own output says why. stado release submit refuses test_products when the submitting Stado is older than the field, as it refuses any recipe key it does not know.

Read a build

stado build status BUILD_ID
stado build status BUILD_ID --wait --json
stado build list --product weles --json

status reads the build's platform jobs from the queue and each finished job's receipt, and saves what changed. A job is judged by its receipt, not by its exit alone: the receipt must name this build, this job, this builder and this source, say passed, and name the archive. The states are:

State Meaning
waiting at least one platform's job is queued or running, or a platform has no job yet
passed every required platform's job wrote a passing receipt and no platform is still building
failed a required platform's job ended without a passing receipt; the platform line carries the reason and the job's last output

Each platform line names its builder, its job id, building, passed or failed, the archive digest once it passed, and the failure sentence when it did not. Under it, status prints what the platform's job did, read from the job's own log, which the builder streams to the queue on every heartbeat: a job still in the queue reads queued since <time> for <host> to claim it; a claimed job that has written nothing reads claimed; its host has streamed no output yet; afterwards every finished step reads step <name>: exit Some(0) after 166s, and a job still building adds step <name>: running for 1320s (since <time>), or that it is preparing the source and toolchain, or between two steps. When the running step's newest output is Cargo's Blocking waiting for file lock on build directory (or on the package cache), that line ends ; blocked: Blocking waiting for file lock … — another Cargo process on this host holds it: the step is not compiling but waiting for another build on the same builder that shares the product's build cache. --json carries the same under progress.<platform>: waiting, steps (each with name, exit and seconds) and running (name, since, seconds, blocked_on). A log the queue cannot read is reported with its error instead of an empty list. Stado Desktop's Releases screen shows both: Read a build and what each platform's job did returns the --json document, and Read what each platform's build job is doing now (steps, durations, waits) returns the text lines above. An optional platform's failure is recorded on the platform and does not fail the build, exactly as it does not fail a release. --wait follows every platform still building to its job's end before answering, so the answer is passed or failed, never waiting; without --json it also writes each queue wait, step start, lock wait and step end to standard error as it happens, rereading the jobs every 15 seconds, and keeps doing so for a platform still building after another has already failed the build. A malformed id is refused with build ID must be 32 lowercase hexadecimal characters; an unknown one with build <id> does not exist. list prints the newest builds first, as recorded, without reading their jobs.

A job that is still building also reads host last streamed output 42s ago (at <time>), and --json carries it as last_output with at and seconds_ago. The builder rewrites the log on every heartbeat, so an age that keeps growing while running for grows with it is a job no host is running any more, whatever the queue still says.

The queue's run reaper retires a settled job's record within minutes and keeps the whole job, as it ended, in its submission's run manifest. A job that ended without a receipt — cancelled, or failed before its worker wrote one — is read back from that manifest, so the platform reads failed with build job <job> ended cancelled (or ended failed) and the job's last output, and the build stops waiting on it. Only a job with no terminal record, no receipt and no retained outcome still reads building.

list prints the newest build records, newest first. A finished build is printed as recorded. A build still waiting, or with a platform still building, is first read against its jobs and saved exactly as status reads it, so a listing never reports waiting for a build whose jobs have all ended. Reading those builds costs one queue read per unfinished build; a build whose record cannot be read against its jobs fails the listing with cannot read build <id> against its jobs: ….

Build the whole workspace

stado build newest --plan
stado build newest
stado build newest --wait
stado build newest --product weles --root /path/to/workspace
stado build newest --queued --wait --root /path/to/workspace

newest reads the workspace the same way stado release newest does — every product checkout, the commit it stands on, the version that commit declares — and queues one build per product. It releases nothing. A version already published is still built when that is the newest commit, because the question is whether the newest commit compiles, not whether it is released; a checkout that declares releases: false or cannot be read is listed with its reason and skipped. --plan reads and queues nothing. Without --wait the command ends when the builds are queued and names each build id; with --wait it follows every build to its end, prints each product's state and failure, and exits nonzero naming every product whose build failed or was refused.

--queued is the daily batch: it builds only the products whose handed-off work (stado release changes submit) no build has taken yet — every ticket stado release changes list reports queued — from the workspace's checkouts, and prints no handed-off work is queued; nothing to build when there is none and <product> has queued work but <workspace> holds no checkout of it for a product it cannot find. It takes no --product. Stado Desktop runs it as Build only the products with handed-off work no build has taken yet. The real journey tests/release/newest/queued.mjs hands off one of two pushed products in a local store and requires --queued --plan to plan only that one, and nothing before the handoff.

--wait holds on the store's change watch, which only the local storage backend has. On a machine whose store is the fleet's object API the command refuses before it queues anything, rather than queueing every build and then failing without the outcome:

--wait cannot follow these builds from this machine: this storage backend has no change notification, so no read can hold until runs/build/ changes; hold reads need the local storage backend. Queue them without --wait and read each with `stado build status <id>`, or run the wait on the host whose store is local

Release a build

stado release submit --build BUILD_ID --channel candidate
stado release submit --source /path/to/product --version 1.4.2 --channel candidate

--build releases a build that has passed: the run records the build it consumes, reads that build's platform jobs and receipts, and signs, publishes, delivers and promotes from them without compiling again. A build that is still waiting or has failed is refused with build <id> is waiting, not passed: a release consumes only a passed build; \stado build status ` shows what its platforms did(oris failed), and no run is recorded. --sourceis the same release from a checkout: it records the build for that commit first and the run queues what the build owes, sostado build statusreads the same jobsstado release statusfollows.--buildand--sourceare exclusive, and--commitand--versionbelong to--source. A release run recorded before builds had records of their own cannot be continued by this release; submitting the same commit again with --source` records its build and continues.

stado release status PRODUCT names the build each run consumes (build=<id>) beside the run's platforms, so the jobs can be asked about with stado build status without reading the run JSON.

A build job's output under status/<job>/output/ — its receipt, its signed archive and its log — belongs to the build that names it, not to the submission run the queue retires. The run reaper retires the queue records of a run-release-platform-* submission on its own cadence and leaves that output alone, because a release is finished on the control host's cadence and reads exactly those bytes. A release job whose queue record is already gone is read from its receipt, which the publish verifies in full (run, job, builder, source, status, archive digest) before anything is published; a job with neither record nor receipt is refused with release job <id> has not reached a terminal state, and left no receipt.

What the forge's continuous integration last did

stado ci status PRODUCT [--revision SHA] [--json] reads, for a catalogued product, what the forge's own continuous integration last did: every workflow the product's repository (its catalog record's owner_repository) declares under .github/workflows, as the forge lists them; for each, the newest run, or the newest run of one full commit with --revision, with its number, status, conclusion, revision, branch, event, start and update times and address; and, for a run that did not pass, every job that failed with the step that failed in it (failedJobs[].failedStep). It reads the forge's API as the identity Stado's GitHub declaration names in Skarbiec, the same identity the runner lifecycle acts as, so no token is typed and none enters the answer.

stado ci status stado
stado ci status stado --revision <full commit> --json

A workflow that has never run, or has no run for the revision named, carries run: null and a finding saying so; a repository that declares no workflow answers workflows: [] and the finding <owner/name> declares no workflow under .github/workflows, so nothing runs for it on the forge. Both are findings about the product, not refusals. Refusals, each by name: a product the catalog does not list (no catalogued product <id>; \stado product catalog` lists the products, a usage error); Skarbiec or the GitHub identity declaration not answering (the declaration's own refusal, within the forge identity Stado acts as is not readable); the forge answering other than success (the forge answered HTTP for the workflows of <owner/name>: , stated in the class the status gives), or not at all. The plain form prints each field as the forge gave it, null` where it gave none. Stado Desktop's Products screen carries Read what the forge's continuous integration last did for a product, which runs the same command on the selected host and shows its answer or refusal as the CLI prints it.

The real journey tests/ci/status.mjs runs the Stado under test against the forge for a product whose repository declares workflows and one whose repository declares none, reads one run again by its revision, asks for a revision no run was made for, prints the plain form and refuses an unknown product. It needs STADO_BIN, a Skarbiec that answers the GitHub identity declaration, STADO_CI_TEST_PRODUCT and STADO_CI_TEST_WORKFLOWLESS_PRODUCT, and writes report.json under .build/ with the commands and their exit statuses.

The fleet's daily build budget

The default ceiling is three native build jobs per UTC day. The canonical registry holds both the count and the ceiling, so all submitters use the same fenced document. A verified user approval can authorize a scoped exception without raising that ceiling.

stado queue budget
stado queue budget --json
stado queue budget --limit 6

budget without --limit reads: the UTC day, the build jobs already submitted on it, the ceiling and what is left. --limit declares a different ceiling deliberately and keeps the count already spent, so raising the ceiling never erases what the day has cost.

Every path that submits a build is charged: stado build submit, stado build newest, a release submitted from source, and a raw stado submit carrying a compile. An already charged run is not charged again on ordinary resubmission. Without a recorded exception, an exhausted budget refuses a new build:

the fleet's daily build budget is spent: 3 of 3 build job(s) submitted on <date> (UTC), and
a release build asks for 1 more. The count resets at <next midnight UTC>. Read it with
`stado queue budget`, and declare a different ceiling — deliberately, once — with
`stado queue budget --limit <N>`.

The count rolls over on its own: a count recorded on an earlier day is history, and the day starts at zero without anybody resetting it.

A recorded user exception

Availability: the approval producer and consumer are implemented in source. Native release, real-fleet consumption and desktop visual qualification are still pending. This documentation does not mean that the currently installed binaries support the exception.

The user must explicitly approve exceeding the daily count for the product or a batch containing it. A normal request to build, or a request to implement an approval mechanism, is not that permission. Record the actual captured message through Tama before submitting:

tama-cli build record --kind build --target PRODUCT --revision FULL_SHA --repository SOURCE \
  --reason "Build this committed change with the user's explicit daily-limit exception" \
  --approval-session SESSION_ID --approval-quote 'VERBATIM_USER_MESSAGE' --json
tama-cli build approval --target PRODUCT --revision FULL_SHA --json
stado build submit --source SOURCE --commit FULL_SHA --json
stado queue budget --json

These are placeholders, not approval supplied by this page. Tama must have captured the real current message for the session. It checks the exact quote and asks Brama to verify its meaning. Missing capture, a changed message, an unavailable verifier, or a refusal stops recording.

Tama Desktop exposes the same writer, refusal and audit history in Policy → Build registry. Recording does not start a build. Closing an entry removes permission for subsequent approval reads but preserves its history and does not cancel jobs already accepted by Stado.

When its budget is spent, native build submission invokes tama-cli build approval for the exact product and source revision. The entry must be open and match Tama's retained audit evidence. Stado then stores the complete evidence with the run ID, platform and recording time in the canonical registry's build_approvals map, in the same fenced write as the charge. stado queue budget --json exposes these records as approvals; its used count may exceed its unchanged limit.

In Stado Desktop, open Releases and choose Read daily build allowance and retained user approvals. This read-only operation runs the same queue budget --json command and shows its complete receipt, including refusal details. It does not raise the ceiling or submit a build.

Each approval can charge one run per product and platform. The same captured message cannot create another Tama intent for that product. Another run cannot reuse the platform receipt; a retry needs new consent and must still satisfy the unchanged source and minimum-change rules. An already approved run remains charged when its worker starts after a UTC-day rollover.

The exception does not bypass source validation, host admission, build checks, expiry, or publication permissions. Raw queue submissions have no product-and-revision approval context and cannot claim an exception. Both Tama's approval reader and Stado's approval consumer must be installed; an older Tama reporting unknown action approval or a missing tama-cli is a refusal, not permission.

The quality gate names whose revision refused

.wisent-release.json declares platforms.<platform>.quality: the argv the release worker executes before it builds, each with its kind: format (the source is formatted as its formatter writes it — cargo fmt --check, Biome, or a web product's stado web quality), lint (a static check that compiles or analyses the source — cargo clippy, a type check) or test (a test the source carries, run before the build). The kind decides which gates a step runs, never its name or argv: a release submission, a release build, stado product update and a change hand-off run the gates a product installation runs — every kind. The Git pre-push check (stado quality check --pre-push, which Tama's dispatcher runs) runs only what compiles nothing — the locks, the module declarations and the format gates — so no session builds before every task and defect is closed; the one build over every repair then meets the lint and test gates and records every refusal at once. stado quality format writes with the format gates only. A gate without kind is refused when the manifest is read (missing field kind), before any gate runs. The pull-request gate reads that same key and runs the same argv, so a pull request is judged by the gate the release stands behind, with one declaration used twice.

The gate program has one copy. .github/workflows/version-check.yml carries it inline and writes it to $RUNNER_TEMP/stado-quality-gate.sh before running it: a job that ran a script out of the tree it is judging could be disarmed by the pull request it is judging. It reads platforms.<platform>.quality from the manifest, so the argv is declared once however many runners exist.

A pull request is judged on its merge result, so a step that already refuses the base branch refuses every pull request opened against it — for files the author never touched. On a refusal the script therefore re-runs that one step against the base revision in its own git worktree and reports:

Verdict Meaning Who repairs it
verdict=introduced the step passes on the base and refuses this revision this change
verdict=inherited the step already refuses the base, at the printed sha the base, in its own change
verdict=unattributed the base could not be checked out, or a clean tree is being compared against its own commit, so ownership is unknown read the printed step and decide

The base is the pull request's base sha, and on a push to main the commit before that push. Comparing main against origin/main compares the revision under judgement against itself, which would answer inherited for every failure and never name the push that caused it; the script refuses that as evidence and says so.

Running it by hand on a checkout with an uncommitted break still attributes: the base sha equals HEAD, but the base's own worktree does not carry the break, so the comparison is real and the answer is introduced.

Every verdict still fails the gate: an inherited failure is a failure, and the release would hit it with a version already spent. What changes is that the message no longer sends the wrong author to reformat somebody else's code.

The script takes --manifest for the manifest it reads and --base for the revision it attributes against. An absent platform, an absent quality key or an empty one is a refusal, not a pass with zero steps.

Without this a formatting slip on main would turn every open pull request red, the gate would tell authors who touched none of those files to "fix the tree", and they would reformat another revision's code to get their own work through.

Source: this website