On this page
Release and Compatibility
This document defines how a Stado source revision becomes an identifiable, immutable, upgradeable, and recoverable product release.
Source handoff and later batch qualification
Writing code does not start a release: how a pushed commit is handed off and qualified with others later is in Source handoff and later batch qualification. Unpushed commits a push review would refuse are folded, dropped, reworded or split before the push by plan, as Rewriting unpushed history describes.
Canonical version
stado-rs/Cargo.toml is the only source of the product version. Binaries expose that version through Clap, release jobs read it from the manifest, and every published object uses it in its immutable coordinate.
A release tag has the form:
stado-v<semver>
The tag version must equal the Cargo package version exactly after removing the stado-v prefix.
Stado follows Semantic Versioning:
- patch: compatible defect, security, and operational corrections;
- minor: backward-compatible public capabilities or optional fields;
- major: an incompatible CLI, machine API, persisted-state, configuration, or provider contract.
Before 1.0, an incompatible preview-integration change may occur in a minor release only when the release notes identify the affected adapter, migration, and rollback boundary.
Stado-native release pipeline
Every product carries one strict .wisent-release.json v1 contract, and a release runs from a committed source through builds, signed publication and deliveries to an exact-digest deployment receipt:
- Submitting and resuming a release: the contract, the version, submission, the ownership chain, resuming a run, and the same operations in Stado Desktop.
- Builders and build recipes: what a platform recipe declares and what the builder reads and measures.
- Publication and deliveries: what is published, how deliveries run and pass, and how a delivery installs on a fleet host.
Release-channel evidence and publication verification
The public release channel is an immutable byte-serving boundary, not a Git tree and not the fleet job store. Every released object has an exact coordinate under:
stado://releases/<product>/<version>/<platform>/
STADO_API_URL, otherwise the deployment's api.url, selects the canonical
HTTP origin. It is an endpoint, not a choice of network provider: direct
HTTPS, a reverse proxy, a tunnel or a provider-managed edge can carry the
same release requests. Repository workflows read the STADO_API_URL repository
variable, defaulting to https://stado.wisent.com; they verify served bytes,
not the state of a particular tunnel program.
stado repair stado --step object-api <target> repairs the target's local object
service. It does not enable, replace or reconcile public ingress. The public
connection belongs to its declared web or gateway configuration and is checked
by the release read-back. The repair runs on the target with the target's own
Stado (~/.stado/bin/stado), so before anything runs it asks that Stado for
every host local api verb the recovery program uses. A target whose Stado
predates one is refused before any step: host_stado_lacks_verb <program> does not declare host local api <verb>: <what that Stado said>, quoted whole in
<target>: object API recovery failed, the host's recovery program ran with its own Stado: exit <N>: <every stderr line, joined with " | ">. Deliver the
current Stado to the target (stado release version converge) and repair
again. Any later failure of the program is quoted the same way, and the
release-store repair reports its failure the same way.
The dashboard serves those objects through GET /api/release/object?uri=.... The route answers for one coordinate at a time; there is no release-listing endpoint. A caller must distinguish three states:
- present — the channel served the object;
- absent — the channel answered authoritatively that the coordinate does not exist;
- unreachable — the channel did not answer, so existence is unknown.
stado storage stat preserves that distinction for release URIs. A network or control-origin failure is infra_down; it must never be treated as absence and must never authorize regeneration or publication.
When the public gateway exhausts its upstream attempts, its HTTP 503 body names
the operation, method, logical URI, selected origin, attempt count and cause.
stado storage stat --json retains that body in an unavailable receipt and
exits 69; a failure before an HTTP response is unreachable.
stado storage get retains the cause in its failure diagnostic, exits 69,
and does not save the error response as the requested object.
What the version gate compares
The version gate does not trust a checked-in released-surface.json. A repository file can describe intended publication, but it cannot prove which bytes the channel serves. The gate's steps are commands of the candidate binary itself, run by .github/workflows/version-check.yml and by every product's own version check: stado release gate surface --binary <stado> (or --help-text <saved help>) prints {"surface": [...]}, the top-level commands the binary advertises; stado release gate baseline --stado <stado> [--output <baseline>] derives the published surface from verified channel bytes and prints stado:<version> for the newest whole release, or bootstrap:<version> on an empty channel; stado release gate decide --current <released> --published-surface <baseline> --candidate-surface <candidate> [--breaking] [--json] is the fleet's port of AutoVersion's rule and names the change class and the next version; stado release gate conformance [--fixtures <FIXTURES.md>] proves that port against AutoVersion's shared fixtures, at the pinned tag when none is named; stado release gate semver reaches <actual> <minimum> succeeds when the declared version is a valid SemVer at least the required one, fails on a lower one and refuses an invalid one with the usage status; stado release gate modules unreachable [--crate <dir>] [--known <list>] [--json] fails when a .rs file under src/ is reachable from no crate root, or when a known entry names a file that is gone; and stado release gate app surface|baseline|check are the same gate for an application bundle, read from the sources its release step names (app baseline --stdout prints the baseline, app check is the quality step an application's manifest declares). The repository has no scripts/ directory: the job deliberately needs no file out of the tree it is judging, and a checked-in copy of a gate it does not run is a program nothing executes. The baseline generator derives its baseline from the newest complete Stado release for which both required native platforms are present:
- ask the release channel for the
linux-amd64anddarwin-arm64manifests; - download each archive named by those manifests;
- verify every archive against the manifest SHA-256;
- extract the Stado binary from each verified archive;
- execute the extracted binaries to derive their advertised command surface;
- require both native artifacts to describe the same surface.
The resulting document records the immutable manifest coordinate as its source. The version gate (release gate decide, given the candidate surface) asks the release channel to confirm that coordinate before comparing it with the candidate binary. A stale local binary, a prose claim, a Git tag alone, or a committed generated file is not release evidence.
The workflow sets STADO_API_URL to the declared control origin and obtains its read credential from Skarbiec. It does not override Stado storage variables to point at a runner-local service: doing that would make the candidate and the baseline answer questions about different stores.
What publication proves
Before the first publication write, stado release submit ensures the managed stado-object-api dependency is available. Publication then writes each immutable release object to the canonical channel, reads that same coordinate back through Stado, and byte-compares the returned content with the source. A successful write without a successful read-back is a failed publication.
Publication acquires the selected product's token field through the independent release.publisher_skarbiec binding. Configure its url, consumer, and token_file, or the WC_RELEASE_PUBLISHER_SKARBIEC_URL, _CONSUMER, and _TOKEN_FILE overrides. The grant must already permit that exact field. Stado refuses a control-plane, release verifier or signing identity in this role, and never widens a grant after refusal. See release publication credentials.
stado storage put --json reports an authorization refusal as a JSON failure without creating the object. An invalid publication identity is refused; a rejected Skarbiec field read is auth. The diagnostic names the operation and selected consumer rather than suggesting a retry with broader credentials.
The signed release manifest remains the commit marker and is written last. Therefore a complete release means all of the following are true:
- every declared object write succeeded;
- every published coordinate was served back;
- every served byte sequence matched its source;
- the manifest was published only after those checks;
- a later version gate can reconstruct the released command surface from the channel bytes alone.
If the control origin is unreachable, the workflow stops with infra_down. It does not replace the canonical channel with local state, reuse an old baseline, or report the release as absent.
One version, one build
An immutable coordinate is bound to exactly one source revision before any artifact byte is written into it. Release objects cannot be rewritten, so a version published twice from two source revisions could never afterwards mean one build, and the only remedy would be publishing the next version.
stado release coordinate claim <PRODUCT> <VERSION> <PLATFORM> --source-commit <REVISION> [--json] performs that binding. It writes two create-only records:
stado://releases/<product>/<version>/source-revision.json
stado://releases/<product>/<version>/<platform>/source-revision.json
The version-scoped record is the arbitration point every publisher and every platform shares; its create-only write closes the race where two publishers each saw no sibling claim and then claimed different platforms from different commits. Each record carries schema_version, product, version, the platform where it is platform-scoped, and source_revision.
The command reports one of three outcomes: claimed, when this revision now owns the coordinate; confirmed, when the coordinate already attests this same revision, so republishing one build is not an error; and a refusal, when the coordinate attests a different revision.
A refusal is a decision this product made, so it carries error_code: refused, retryable: false and failure_point: cli.release.coordinate.claim, and a --json caller receives it as one document. The exact sentences:
<product>/<version> already attests source revision <held>, and this publisher carries <candidate>. Release objects are immutable: publish a new version<product>/<version>/<platform> already attests source revision <held>, and this publisher carries <candidate>. Release objects are immutable, so one version can never mean two builds: publish a new version instead of writing a second build into this coordinate<product>/<version> already carries platform <platform> from source revision <held>; this publisher carries <candidate>. A version's platforms are one build: publish a new version
Because a claim cannot be taken back, stado release submit --source checks the exported commit on the submitting host before it claims anything: the lockfile must resolve, every pinned input must be stored, and every quality gate the manifest declares for this host's platform must pass — for Stado fmt and clippy, printed as stado quality check: cargo clippy --manifest-path stado-rs/Cargo.toml --locked --all-targets --keep-going -- -D warnings. A refusal prints the gate's own report, for example stado quality check: gate "fmt" of stado refuses <commit> … cargo fmt --manifest-path stado-rs/Cargo.toml --all -- --check exited 1, and the version stays free for the corrected commit. Build steps, tests and the other platform's gates still run only on the builders after the claim. stado quality check on its own, and change hand-offs, run the formatting gates only.
stado release promote refuses a channel move whose platforms disagree, with release platforms were not built from one source revision, and stado doctor reports a version whose version-scoped and platform-scoped claims do not attest one revision.
Product catalog
Stado owns product release policy independently of repository hosting:
stado release catalog sync --catalog /path/to/release-catalog.json
stado release catalog audit
Sync imports the fleet's reviewed central catalog, including explicit
releases:false manifests, refuses missing or duplicate product names, and
CAS-updates stado://system/release-catalog/<product>.json. --root ROOT
remains available for bootstrapping a catalog from local registered checkouts.
Submit records the strict manifest together with its immutable source identity
before queueing work. Audit reads only Stado catalog objects and refuses
malformed, duplicate, or silent catalogs; it does not enumerate a Git forge or
require forge tokens.
Compatibility matrix
| Contract | Compatibility rule |
|---|---|
| Human CLI | flags and commands may be added compatibly; removal or semantic reversal requires a major release |
| Machine JSON | clients send and receive a supported schema_version; additive optional fields are compatible |
| MCP | protocol version and tool schemas are explicit; mutation authority is never added to the read-only server implicitly |
| Job JSON | unknown additive fields are preserved where round-trip ownership requires it; incompatible required fields require migration |
| Queue/storage layout | readers reject unsupported future schema versions; writers never silently downgrade canonical state |
| Configuration | the root schema version is required; migration produces a new document and preserves the prior file for rollback |
| Coordinator/agent | a release manifest declares the minimum compatible agent; dispatch refuses an incompatible agent |
| Provider adapters | implemented capability does not imply stable support; the released capability catalog and live evidence define support |
Upgrade procedure
- Read release notes and compatibility range.
- Record current exact version and platform.
- Run
stado doctor --fix-hints. - Pause new work and drain running work when the release changes state or execution contracts.
- Verify the configured backup destination.
- Copy and verify canonical state when a schema migration requires a recovery point.
- Download the exact candidate through the public release route.
- Verify manifest identity and SHA-256.
- Install atomically while preserving the prior binary.
- Let the declared release agent reconcile the selected canary; do not manually restart its predecessor.
- Verify version, health, queue visibility, and one representative workload.
- Resume dispatch only after the candidate evidence is clean.
When the agent fetches a candidate it tells two failures apart. A manifest,
signature or archive the release channel did not serve says nothing about the
release: the digest is not quarantined, the state detail reads cannot fetch <product> <version> from the release channel; read again next pass with the
store's own error and class, and the next pass fetches again. Bytes that were
served and refused — a digest that does not match, a manifest that fails its
schema or the registry's desired state, an untrusted key or a bad signature, a
candidate that has not passed qualification or needs a newer Stado — quarantine
the digest, and stado release doctor shows that reason.
A legacy service still owns the stable port
The blue-green agent starts the candidate on a separate declared candidate
port. A system LaunchDaemon explicitly named by legacy_launchd_label and
legacy_launchd_plist may continue serving the stable port during admission.
The agent uses the same native label and parent-chain reader as
stado service serving, not a process-name comparison. Every observed listener
must belong to that declared predecessor. Missing ownership evidence, a user
LaunchAgent, an unrelated listener or overlapping candidate and stable ports
does not receive this exception.
Signature and qualification verification still precede candidate startup. Only after candidate readiness passes does the existing cutover stop the declared predecessor and bind the release proxy. Existing rollback restores the predecessor when there is no previous release. Admission itself does not stop, retire or remove the legacy service.
Inspect the actual owner and rollout through the normal read interfaces:
stado service serving <legacy-unit> --host <target> --port <stable-port> --json
stado release doctor <product> --target <target> --json
service list lists the fleet and has no --host option. Use
stado service show <legacy-unit> --host <target> --json to restrict a
declaration read to one host. service handoff finalizes a
settled release; it cannot create a candidate and is not a port-repair command.
The Desktop service command and release diagnostics expose these same results;
the GUI does not decide ownership independently.
Rollback procedure
Rollback uses the exact previously recorded release coordinate; it never rebuilds source in place.
- Pause dispatch.
- Stop the affected service.
- Restore the previous verified binary atomically.
- Restore the previous configuration file if configuration migration occurred.
- Restore canonical state only when release notes explicitly state that the new writer produced an incompatible layout.
- Restart and verify version, storage reachability, queue counts, and agent compatibility.
- Resume after one successful local workload.
If a migration is forward-only, the release notes must say so before installation and the rollback procedure must restore a pre-migration state copy.
When Stado itself cannot deliver
The upgrade and rollback procedures above assume the host's Stado binary and
resolver still work. If either is missing or broken, do not turn those steps
into an ad-hoc SSH copy. Use the single
versioned host-recovery procedure:
it restores the registry-selected release object API before its first catalog
read, fetches the exact canonical signed artifact without the local resolver or
a remote Stado binary, verifies manifest/signature/SHA-256, installs atomically,
and restores the preserved binary automatically if the remote resolver probe
fails. Routine declaration-driven delivery remains host release; the recovery
path exists only to restore Stado itself.
Release notes contract
Every candidate and stable release records:
- source commit and immutable artifact coordinates;
- user-visible changes;
- fixed failures and security changes;
- CLI and machine-contract changes;
- configuration and persisted-state migrations;
- provider capability changes;
- supported platforms;
- known limitations;
- required operator actions;
- upgrade and rollback instructions;
- acceptance evidence and exclusions.
User-visible history lives in CHANGELOG.md. Incident detail is operator material and stays out of this repository.
Keeping the changelog editable
A repository that keeps released entries in changelog/ writes new entries under ## Unreleased in CHANGELOG.md, and two commands move them so no file passes the repository's length limit. Both take that limit as --line-limit, because the limit is the repository's rule rather than Stado's.
stado product changelog --spill --line-limit <limit>
stado product changelog --version <version> --line-limit <limit>
--spill is for a release that gathers more entries than CHANGELOG.md can hold while it is prepared: it moves the entries written so far into changelog/unreleased-part-N.md files, each within the limit, and leaves only their links under ## Unreleased, so the next entry can be written again. A part cut out of one ### Added or ### Fixed group opens with that heading, and no entry is split across parts. It prints {"changelog", "spilled", "parts"}.
--version runs in the version-bump commit. It gathers every spilled part, oldest first, with what the section still holds, groups the entries under one copy of each heading, writes them as that release into the newest range file (or into new changelog/<version>.md parts when that file has no room), removes the gathered parts and empties the section. It prints {"changelog", "moved", "version", "range_file", "gathered_parts"}.
Refusals name what is wrong: a missing --line-limit, neither --spill nor --version, a limit that leaves no line once a part's own heading and preamble are written, an entry longer than the room, an unreleased-part-N.md link whose number does not read, a linked part that is missing or holds no ## Unreleased section, and a part file that already exists but is not linked. stado build submit refuses a revision whose Unreleased section still holds entries or spilled-part links, and names the --version command to run.
Stado Desktop runs the same move as Products → Move CHANGELOG.md's Unreleased entries into changelog/: the repository checkout and the line limit are required, and either a release version or Spill is set; the answer and every refusal are shown as the command prints them.
Promotion gate
A candidate may become stable only when:
- the version/tag/source relationship is exact;
- all required platform artifacts and manifests exist;
- checksums verify;
- onboarding succeeds from a clean supported environment;
- core contract suites pass;
- every stable integration has current live acceptance evidence;
- upgrade and rollback succeed on a canary;
- release notes describe all compatibility and migration effects;
- an operator explicitly promotes the immutable candidate.
Source: this website