Distribution and packaging
Aether has two packaging commands with different consumers:
cargo xtask distbuilds a discoverable development/test artifact tree.cargo xtask packageemits a shippable package depot: one chassis binary plus a content-addressed pack of components.
Neither command is the same as merging a PR. Tagging a version is what
publishes a GitHub Release, and it does so by running cargo xtask package on
each platform — see Cutting a release.
Distribution tree
cargo xtask dist is the dev/test artifact channel: the harnesses locate the
headless and hub binaries and component wasm by stem through dist/manifest.json,
and CI pre-builds scenario wasm through it. It discovers wasm components
structurally from Cargo metadata — a package depends on aether-actor and
exposes a cdylib target — builds each component package in an isolated Cargo
invocation for wasm32-unknown-unknown, optionally builds the chassis
binaries, and writes an authoritative dist/ tree.
dist/
manifest.json
components/
<stem>.wasm
bin/
aether-desktop
aether-headless
aether-hub
The manifest records target, profile, component paths, and chassis paths
relative to dist/. --no-bins provides a wasm-only fast path. The command
regenerates dist/ rather than allowing stale artifacts to masquerade as the
current manifest.
Chassis binary inventory
xtask/src/inventory.rs holds the one list of chassis binaries the workspace
ships, and cargo xtask bins publishes it so a script or workflow reads the
names instead of re-spelling them:
cargo xtask bins # one `<package> <bin> <file>` line per binary
cargo xtask bins --json # the same, plus the depot filename per `--chassis`
file is the host-platform filename, so a Windows runner is told
aether-desktop.exe. The --json form adds package_chassis, keyed by the
values cargo xtask package --chassis accepts. .github/workflows/release.yml
reads both: package_chassis.desktop tells it which binary the depot already
carries, and each remaining chassis_bins entry is a binary it builds and
ships as its own archive. Prefer this over hardcoding a binary name — no pull
request runs that workflow, so nothing in CI catches a name that has gone
stale.
Package depot
cargo xtask package is the shipping channel (ADR-0163 §1). It emits a depot
directory: the chassis binary alongside a pack/ tree whose manifest
references each component's wasm (and optional config) bytes by content hash
into pack/objects/, and lists the named objects the depot ships for a running
engine to read by path.
<out>/
aether-desktop # the chassis binary (`aether-headless` under `--chassis headless`; .exe on Windows)
LICENSE-MIT # the workspace licenses, shipped beside the statically linked binary
LICENSE-APACHE
pack/manifest # the persisted, versioned package manifest
pack/objects/<sha256> # component wasm + config bytes and named objects, content-addressed
pack/assets/… # the `--assets` tree, verbatim
The depot writes to target/package/ unless --out names another directory.
The chassis boots by decoding pack/manifest and resolving each entry's hash
against pack/objects/, so identity is the content and a name is a label.
A depot ships release artifacts, so --profile defaults to release.
The manifest format is version 3, which added the table of named objects. A
chassis refuses a manifest of another version, so a depot built before it is
re-emitted with cargo xtask package.
Selecting content
With no --components and no --spec, package runs the discover-everything
dev sweep: every structurally discovered component, the desktop chassis, and
default settings, with names mirroring the dist wasm stems. The sweep spawns
only the exports that declare root and prints each one it skips.
For a real product, name the chassis and the components:
cargo xtask package \
--profile release \
--chassis desktop \
--components my-game \
--title aether
my-game stands for a component crate whose module exports exactly one
selectable type. A module exporting several, such as aether-kit, needs the
--spec form described below, naming an export, and
crates/aether-demo/demo.json is the worked spec.
--chassis selects desktop or headless. Component order is autoload order.
Repeated --config flags pair by position with --components; trailing
components may omit config. --title, --window-mode, and --clear-color
apply to the desktop chassis; --tick-hz applies to headless. Those four
settings ride into pack/manifest and the depot boot applies them below
argv/env and above the compiled defaults, so a shipped depot comes up titled, in
its window mode, and on its own background while an operator's
AETHER_WINDOW_* / AETHER_RENDER_CLEAR_COLOR still overrides it.
--clear-color is sRGB rrggbb hex; what the background should be is a
property of what is drawn, so a product that is a line drawing ships its paper
here rather than asking every operator for a flag.
For explicit actor export, instance name, or richer per-component control, use
the JSON --spec form. A spec carries the chassis, the four chassis settings,
and per-component package-or-wasm plus config / config_json, name, and
export. Relative paths in a spec resolve against the spec file's directory,
not an arbitrary process working directory.
config names a file of init-config bytes — the wire image of the
component's Config kind, which is what a machine stages. config_json names a
JSON file instead, encoded at build time against the Config schema the
component's own wasm declares (ADR-0090 + ADR-0028). Prefer config_json for
anything checked in: a reviewer can read it, and a field the component does not
declare fails the emit naming the file and the field rather than arriving as a
decode error inside the guest. The JSON boot manifest takes the same pair of
fields, so a spec and a manifest can share one config file. Setting both on one
entry is an error, not a precedence question.
Naming objects boot does not load
A boot component is loaded when the engine starts. A product that pages content
in later (a module per map square, say) ships those modules as named
objects: each is written into pack/objects/ under its sha256 like any other
object, and the manifest lists it under a path with that hash and its size. A
running actor reads it by that path through the objects file namespace and
publishes the blob it is answered with; see File I/O.
A spec's named list holds one-key entries, one per source:
{
"components": [{ "package": "my-game" }],
"named": [
{ "package": { "name": "my-game-cave", "path": "modules/cave.wasm" } },
{ "wasm": { "file": "build/boss.wasm", "path": "modules/boss.wasm" } },
{ "dir": { "from": "build/squares", "under": "world/squares" } }
]
}
packagebuilds a workspace package for wasm32 and ships its lib cdylib atpath.wasmships a prebuilt file atpath.dirships every file beneath the directoryfrom, each at<under>/<its path relative to from>.
On the command line, --named <UNDER> <DIR> is the directory form; repeat it
for more directories. It needs --components, and a single object goes through
--spec.
cargo xtask package \
--components my-game \
--named world/squares build/squares
A path is lowercase: segments of a-z, 0-9, ., _ and - joined by single
/. A path or under that breaks the rule fails the spec parse, and a file
in a directory whose resulting path breaks it fails the run naming the file;
nothing is skipped, so a stray .DS_Store is removed before packaging, not
shipped or ignored. Two entries that resolve to one path fail the run naming
both files. The run prints each named object's path, sha256 and size.
When the depot boots, the objects namespace checks that every named object is
present under pack/objects/ at the size the manifest records, and the boot
fails naming the object when one is not: a truncated install fails at boot, as
one missing a boot component does, and not at the moment an actor first reads
the path. The check compares sizes; it does not read or hash the bytes.
Shipping assets
--assets <dir> copies a directory verbatim into pack/assets, and the
packaged chassis roots the assets namespace there. Assets are the one part of
pack/ that is not content-addressed, and deliberately: a component reaches a
file by mailing aether.fs.read with the path an author wrote, so the shipped
tree has to keep those paths. Objects are hash-named because the manifest names
them; assets are path-named because the running program does.
The depot's root slots in below argv/env/file and above the compiled
default, the same precedence a manifest's title and tick cadence take, so an
operator's AETHER_ASSETS_DIR / --assets-dir still overrides a shipped depot.
The check is per member rather than per field: any pinned aether.fs root — save
or config as much as assets — keeps the operator's whole NamespaceRoots.
The objects namespace takes the same slot: a packaged chassis reads the
depot's named objects by path out of pack/objects, and AETHER_OBJECTS_DIR /
--objects-dir or any other pinned root replaces that with the plain directory
it names, where an object is the file at its path. With no package it is the
objects directory beside the binary, which need not exist. See
File I/O.
cargo xtask package \
--spec crates/aether-demo/demo.json \
--assets crates/aether-mesh/examples
That is the checked-in demo (crates/aether-demo/README.md): a depot that
draws the teapot.dsl mesh, framed and steerable from the keyboard, when its
binary is run with no flags at all. The mesh viewer draws nothing until it is
sent aether.kit.mesh.load, so the demo's last component, aether.demo, sends
it that load at wire, the same mail an operator would.
Boot-time manifests
Two boot channels feed a chassis its component set, and they are distinct from the persisted package manifest above:
- The JSON boot manifest (
crate::boot_manifest) names component files by path. The hub'sspawn_substratewrites it and addresses it to the child as--boot-manifestargv; the spawned chassis reads the listed wasm itself. Its entries takeconfig(bytes) orconfig_json(encoded at read time against the component's declaredConfigschema), so a checked-in manifest is the no-packaging developer path —--boot-manifest crates/aether-demo/demo.boot.jsonboots the same composition the depot ships. Manifest paths are resolved as-is, against the process working directory rather than the manifest's own directory. - The package manifest (
crate::package) references bytes by content hash and is what a shipped depot boots from.
Both drain into the same env.autoload list, which boot_standard loads in
list order after the build, waiting for every component to answer its load
before the RPC server binds. A boot load that does not answer within the
boot-load budget (AETHER_BOOT_LOAD_BUDGET_SECS / --boot-load-budget-secs,
default 20 s, never zero) fails the boot, naming the component, so a
hub spawn fails with that name in its spawn_failed detail. The runtime boot
manifest can expand a configured replicas count into counter-keyed
instances, not named ones; the package manifest carries the same replicas
field.
Choosing a packaging path
| Goal | Use |
|---|---|
| Let tests or an external harness locate every current artifact | cargo xtask dist |
| Build only component wasm quickly | cargo xtask dist --no-bins |
| Run an agent-controlled fleet | hub + uploaded binary/component registries |
| Ship a precomposed depot | cargo xtask package |
| Exercise runtime code without packaging | Cargo run/test or SubstrateHarness |
Release terminology
Keep these operations distinct:
- land: merge an approved PR through the repository workflow;
- dist: produce the development/test artifact tree;
- package: produce a shippable package depot;
- bump: move the workspace version and re-lock — see below;
- release: push the version tag, which packages every platform and publishes the archives on that tag's GitHub Release — see Cutting a release.
ADR-0092 proposes a release-branch workflow but remains Proposed; it is not current repository policy. Contributor lifecycle skills do not publish a software release — the tag push does.
Bumping the workspace version
Every crate takes its version from [workspace.package] version in the root
Cargo.toml — no crate carries a literal, and no doc, script, or workflow
spells one either. Every env!("CARGO_PKG_VERSION") site reads it at compile
time. So the bump is one edit and the lockfiles that edit invalidates:
cargo xtask bump 0.4.0-alpha --dry-run # print the files and the commands
cargo xtask bump 0.4.0-alpha # write them
The command refuses an argument semver will not parse, and refuses a version the workspace is already on.
There are two lockfiles. fuzz is a [workspace] exclude entry with its
own standalone workspace and its own Cargo.lock, which pins aether-codec
and aether-data by version through path dependencies — a root cargo update
never opens it, and skipping it leaves the fuzz build resolving against a
version that no longer exists. bump finds it by reading the exclude list
and re-locking every excluded crate that carries a lockfile, so a future
excluded crate is covered without editing the command. Re-locking fuzz needs
network access: its lockfile lags the workspace, so the resolve pulls crates
the offline cache may not hold.
The cut is four steps:
- Bump.
cargo xtask bump <version>, dry-run first. - Read the three files it touched —
Cargo.toml,Cargo.lock,fuzz/Cargo.lock. The two lockfiles should show the workspace crates moving to the new version;fuzz/Cargo.lockmay also carry registry churn it had accumulated while nothing re-locked it. - Land the bump as its own PR (
chore(release): …) through the ordinary flow. Nothing else rides that PR, so the version move is one commit. - Tag the merged commit and push the tag, which publishes the release — see below.
Cutting a release
.github/workflows/release.yml turns a version tag into a published GitHub
Release. It triggers on a push of a bare-semver tag; the repository's tags
carry no v prefix (0.1.0-alpha, 0.3.0-alpha), so once the bump above and
the version's CHANGELOG.md section are on main the cut is:
git tag -a 0.4.0-alpha -m "…"
git push origin 0.4.0-alpha
Each platform builds a package depot from the checked-in
crates/aether-demo/demo.json spec plus one archive per remaining chassis
binary, and every archive is attached to the release:
| Platform | Depot | Chassis binaries |
|---|---|---|
linux-x86_64 | aether-<version>-linux-x86_64.tar.gz | aether-headless-…, aether-hub-….tar.gz |
macos-arm64 | aether-<version>-macos-arm64.tar.gz | aether-headless-…, aether-hub-….tar.gz |
windows-x86_64 | aether-<version>-windows-x86_64.zip | aether-headless-…, aether-hub-….zip |
Every name carries <version>-<platform>, and the platform half is read from
the toolchain's own host triple, so it follows a runner image that changes
architecture rather than asserting a stale one. Each archive unpacks to a
single directory of that same name.
The depot archive holds what the spec says: the chassis binary it names, the
components it selects, and both workspace license files. The spec owns that
list, which is why the other chassis binaries are their own archives rather
than extra files inside a depot they are not part of — each ships with the
same two license files, since a statically linked binary is redistributed
with its notices. The set of those binaries comes from
cargo xtask bins --json, so a chassis added to the inventory ships from the
next tag with no workflow edit.
The release is marked a pre-release whenever the version carries a
pre-release suffix, which every tag cut so far does (-alpha). Its body is
CHANGELOG.md's
section for that version, and the tag's own message when the changelog carries
no such section. The heading is matched on its first word, so
## 0.4.0-alpha (unreleased) resolves the same as ## 0.4.0-alpha, and only
a ## heading ends a section — the ### subheadings inside one stay part of
it. So the changelog section is written before the tag, not after: what it
says at the tagged commit is what the release page shows.
Running the workflow from the Actions tab (workflow_dispatch) is the dry
run: the identical build, archives named from the current workspace version
and uploaded as workflow artifacts, and no release created. Reach for it to
exercise a change to the workflow before a tag depends on it.
Verification and cleanup
Packaging is intentionally expensive and can leave large target/ and dist/
trees. For normal implementation PRs, CI owns the expensive distribution proof
unless the issue or user asks for a local build. If you run it locally, report
what was generated and reclaim those artifacts when no longer needed.
Validate a change at the boundary it touches:
- component discovery changes: inspect
dist/manifest.jsonand the fixture set; - package manifest format changes: round-trip
aether_chassis::packagetests; - autoload changes: boot a depot with multiple or export-selected components;
- chassis option changes: test the matching desktop or headless binary;
- release workflow changes: verify the workflow artifact, not only local Cargo.
Implementation routes
- Discovery and commands:
xtask/src/{main,inventory}.rs - Version bump + lockfile regeneration:
xtask/src/bump.rs - Published chassis-binary inventory:
xtask/src/bins.rs - Autoload:
crates/aether-chassis/src/autoload/ - Boot manifest schema:
crates/aether-chassis/src/boot_manifest.rs - Package manifest + store-backed boot:
crates/aether-chassis/src/package.rs - JSON init-config encoding:
crates/aether-chassis/src/component_config.rs - Current hosted artifact job:
.github/workflows/release.yml - Related decisions: ADR-0090, ADR-0115, ADR-0116, ADR-0163; ADR-0092 is Proposed