Distribution and packaging

Aether has two packaging commands with different consumers:

  • cargo xtask dist builds a discoverable development/test artifact tree.
  • cargo xtask package emits 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" } }
  ]
}
  • package builds a workspace package for wasm32 and ships its lib cdylib at path.
  • wasm ships a prebuilt file at path.
  • dir ships every file beneath the directory from, 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's spawn_substrate writes it and addresses it to the child as --boot-manifest argv; the spawned chassis reads the listed wasm itself. Its entries take config (bytes) or config_json (encoded at read time against the component's declared Config schema), so a checked-in manifest is the no-packaging developer path — --boot-manifest crates/aether-demo/demo.boot.json boots 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

GoalUse
Let tests or an external harness locate every current artifactcargo xtask dist
Build only component wasm quicklycargo xtask dist --no-bins
Run an agent-controlled fleethub + uploaded binary/component registries
Ship a precomposed depotcargo xtask package
Exercise runtime code without packagingCargo 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:

  1. Bump. cargo xtask bump <version>, dry-run first.
  2. 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.lock may also carry registry churn it had accumulated while nothing re-locked it.
  3. 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.
  4. 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:

PlatformDepotChassis binaries
linux-x86_64aether-<version>-linux-x86_64.tar.gzaether-headless-…, aether-hub-….tar.gz
macos-arm64aether-<version>-macos-arm64.tar.gzaether-headless-…, aether-hub-….tar.gz
windows-x86_64aether-<version>-windows-x86_64.zipaether-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.json and the fixture set;
  • package manifest format changes: round-trip aether_chassis::package tests;
  • 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