From 6fa52178a97ae9f31afc1d78d0f4cf7bf7a2725c Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Mon, 27 Jul 2026 10:14:29 +0100 Subject: [PATCH] Versioned releases: publish first-party packages to the mote channel Replaces rsync as the *deployment* mechanism with versioned conda packages on the prefix.dev `mote` channel. `pixi run sync` stays the development loop; a robot now runs an environment resolved from a channel, with no source checkout and no colcon, and moves between versions with rollback. Ten packages are released, built by pixi-build-ros straight from each package.xml: the eight first-party ones at a single repo-wide semver, plus sllidar_ros2 and kinematic_icp. The submodules are not optional -- a robot installing from the channel has no source tree to build them from, and without them it has no lidar driver and no odometry. They build from out-of-tree manifests under release/third_party, because a pixi.toml inside a submodule would be an untracked file in someone else's repo. mote_simulation is excluded by policy: workstation-only, and already excluded from sync. One version for all first-party packages, source of truth [workspace] version, propagated by release/version.py into every package.xml and setup.py. The per-package build manifests deliberately carry no version, so there is exactly one place to bump. `pixi run release` is the whole dry run and uploads nothing: tests, then a build into a local indexed channel, then release-verify, which renders the robot deploy manifest against that channel and resolves it. That last step is the one with teeth -- it caught mote_fleet depending on a nonexistent ros-jazzy-python3-paho-mqtt, the backend's fallback naming for a rosdep key RoboStack has no entry for. Every package had built fine. Publishing stays a separate, human, typed-confirmation step; CI builds and verifies both architectures on a tag but does not upload. Robots update by version into slots under ~/mote-deploy with an atomically flipped `current` symlink (release/deploy/mote-update: stage/cutover/rollback/ prune). Cutover is stop-then-start -- the Pi has neither the CPU for two ROS stacks nor a second set of serial ports -- health-gated, and self-rolls-back if the units do not return. $MOTE_HOME is outside the deploy root entirely, so an update cannot touch identity, maps or calibration. Supporting fixes this exposed: mote_bringup now ships udev/, systemd/, networkmanager/ and tailscale/ into share/, because a checkout-free robot still has to run `pixi run setup`; systemd/install.sh honours a MOTE_REPO override and resolves the DDS config path for either layout, with the units pointed at the `current` symlink so a cutover redirects them without reinstalling. Verified: all 410 existing tests pass; 32 new release-tooling tests, including a full stage/cutover/rollback/prune cycle against a stub pixi that asserts ~/.mote comes out byte-identical; `pixi run release` completes end to end on linux-64 with the dry-run evidence committed under release/evidence/. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Me6xVFErTy21hnARnMLt9M --- .github/workflows/release.yml | 65 +++++ .gitignore | 9 + CLAUDE.md | 16 +- README.md | 36 ++- docs/releasing.md | 257 +++++++++++++++++ mote_arm/package.xml | 2 +- mote_arm/pixi.toml | 8 + mote_arm/setup.py | 2 +- mote_bringup/package.xml | 2 +- mote_bringup/pixi.toml | 8 + mote_bringup/setup.py | 19 +- mote_bringup/systemd/install.sh | 46 ++- mote_bringup/systemd/mote-agent.service | 6 +- mote_bringup/systemd/mote-bringup.service | 6 +- mote_bringup/systemd/mote-health.service | 6 +- mote_bringup/systemd/mote-nav.service | 6 +- mote_bringup/systemd/mote-record.service | 6 +- mote_bringup/systemd/mote-slam.service | 6 +- mote_description/package.xml | 2 +- mote_description/pixi.toml | 8 + mote_fleet/package.xml | 2 +- mote_fleet/pixi.toml | 16 ++ mote_fleet/setup.py | 2 +- mote_hardware/package.xml | 6 +- mote_hardware/pixi.toml | 11 + mote_nav/package.xml | 2 +- mote_nav/pixi.toml | 8 + mote_perception/package.xml | 2 +- mote_perception/pixi.toml | 8 + mote_perception/setup.py | 8 +- mote_tasks/package.xml | 2 +- mote_tasks/pixi.toml | 8 + mote_tasks/setup.py | 2 +- pixi.lock | 70 +++++ pixi.toml | 38 +++ release/build.py | 156 +++++++++++ release/deploy/mote-update | 292 ++++++++++++++++++++ release/deploy/pixi.toml | 140 ++++++++++ release/deploy/pixi.toml.in | 91 ++++++ release/deploy_manifest.py | 164 +++++++++++ release/evidence/dry-run-linux-64.json | 60 ++++ release/test/test_mote_update.py | 202 ++++++++++++++ release/test/test_release_tooling.py | 227 +++++++++++++++ release/third_party/kinematic_icp/pixi.toml | 21 ++ release/third_party/sllidar_ros2/pixi.toml | 12 + release/verify.py | 164 +++++++++++ release/version.py | 165 +++++++++++ 47 files changed, 2347 insertions(+), 48 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 docs/releasing.md create mode 100644 mote_arm/pixi.toml create mode 100644 mote_bringup/pixi.toml create mode 100644 mote_description/pixi.toml create mode 100644 mote_fleet/pixi.toml create mode 100644 mote_hardware/pixi.toml create mode 100644 mote_nav/pixi.toml create mode 100644 mote_perception/pixi.toml create mode 100644 mote_tasks/pixi.toml create mode 100755 release/build.py create mode 100755 release/deploy/mote-update create mode 100644 release/deploy/pixi.toml create mode 100644 release/deploy/pixi.toml.in create mode 100755 release/deploy_manifest.py create mode 100644 release/evidence/dry-run-linux-64.json create mode 100644 release/test/test_mote_update.py create mode 100644 release/test/test_release_tooling.py create mode 100644 release/third_party/kinematic_icp/pixi.toml create mode 100644 release/third_party/sllidar_ros2/pixi.toml create mode 100755 release/verify.py create mode 100755 release/version.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..d7ccb30 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,65 @@ +name: release + +# Builds the release for both architectures and proves a robot environment +# resolves against it. Deliberately does NOT publish: the upload to +# prefix.dev/mote is a human action (`pixi run release-publish`), because a +# published version cannot be taken back out of someone's lockfile. +# +# See docs/releasing.md. + +on: + push: + tags: ["v*"] + # Cutting a release should never be the first time the release builds, so it + # can also be run by hand against a branch. + workflow_dispatch: + +jobs: + release: + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-latest # linux-64 + - runner: ubuntu-24.04-arm # linux-aarch64, same arch as the Pi + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v4 + with: + # kinematic_icp and sllidar_ros2 are released too -- a robot + # installing from the channel has no source tree to build them from. + submodules: recursive + + - uses: prefix-dev/setup-pixi@v0.9.0 + with: + cache: true + + # Cheap and ROS-free, so it fails in seconds rather than after a build: + # catches a package missing from the release set, an unpinned backend, or + # a stale committed deploy manifest. + - name: Test the release tooling + run: pixi run test-release + + - name: Check version consistency + run: pixi run release-version check + + # The package build does not run tests (BUILD_TESTING=OFF), so they run + # here, before anything is built. + - name: Test + run: pixi run test + + - name: Build the release + run: pixi run release-build + + # The step with teeth: resolves the robot deploy manifest against the + # freshly built channel, proving the release installs as a set. + - name: Verify a robot environment resolves + run: pixi run release-verify + + - uses: actions/upload-artifact@v4 + with: + name: mote-packages-${{ matrix.runner }} + path: | + dist/channel/**/*.conda + release/evidence/ + if-no-files-found: error diff --git a/.gitignore b/.gitignore index 1b0d5ae..3c90b4c 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,15 @@ __pycache__/ scratchpad* +# setuptools install-record dropped in each ament_python package by a package +# build (`pixi run release-build`). Full of absolute build-prefix paths. +files.txt + +# Built release artifacts and the local dry-run channel (`pixi run release`). +# The packages are never committed; what a release *does* commit is +# release/evidence/ and the regenerated release/deploy/pixi.toml. +dist/ + # Sim benchmark outputs (timestamped metrics/reports) benchmark_results/ # Bag-replay scoring outputs (timestamped metrics/reports/maps) diff --git a/CLAUDE.md b/CLAUDE.md index d699049..eab80f8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,7 +23,15 @@ pixi run arm # SO-101 arm driver: joint states + safe jog control pixi run arm-jog # Interactive per-joint jog CLI (needs `pixi run arm`) pixi run arm-check # Standalone arm bus enumeration + health (read-only) pixi run arm-pose # Teach/replay named arm poses; derive soft limits -pixi run sync # rsync project to Pi at SSH host 'mote' +pixi run sync # rsync project to Pi at SSH host 'mote' (dev loop only) +# Releases: versioned packages on the prefix.dev `mote` channel (docs/releasing.md) +pixi run release-version # show / check / set X.Y.Z (one version, all packages) +pixi run release # full dry run: test + build all + verify. Uploads nothing +pixi run release-build # build the release set -> dist/channel (local channel) +pixi run release-verify # resolve a robot env against dist/channel +pixi run release-manifest # regenerate the committed release/deploy/pixi.toml +pixi run release-publish # THE outward-facing step: upload to prefix.dev/mote +pixi run test-release # tests for the release tooling (own minimal env, no ROS) pixi run setup # One-time Pi setup: udev + wifi-powersave + systemd (needs sudo) pixi run udev # Install udev rules + dialout group (needs sudo) pixi run wifi-powersave # Disable WiFi power save via NetworkManager (needs sudo) @@ -99,6 +107,10 @@ Milestone M1 of `docs/design/fleet.md`, built in the **`mote_fleet`** package (b Milestone M3 of `docs/design/fleet.md`, and the end of v0. The **HTTP** wire is specified as its own versioned contract in **`docs/fleet/fleet-api.md`** (M1's MQTT one is `control-plane.md`); the operator flow is `docs/fleet/README.md` §6–9 and the measurements are `m3-verification.md`. **The two directions of the loop take different paths on purpose.** *Reads* ride MQTT: the browser subscribes to `mote/v1/+/{presence,health,pose,task/status}` over WebSockets, and because all of those are retained it has the whole fleet's state within a second of loading — no polling, no service in the middle. *Writes* ride HTTP: `POST /v1/robots//dispatch` authorizes an operator token (`fleetctl operator new --name `; the name is what the audit row records), writes the audit row, then publishes to the same `task/command` topic. **The topic tree did not change — only who publishes to it**, and `fleetctl dispatch` moved to the API too, so there is one write path rather than one per client. The command grammar is still parsed only by the robot's task layer: a parser in the server would be a second grammar to keep in step. **The browser cannot publish**: `server/ui/mqtt.mjs` is a hand-rolled subscribe-only MQTT 3.1.1 client that implements no PUBLISH packet, so the split is enforced by omission (M7 makes it structural with a subscribe-only broker credential). The UI is static ES modules — no bundler, no npm, no vendored library — served by the same stdlib `http.server`; `map.mjs` holds the Q5 world→pixel transform (`px = (wx-origin_x)/res`, `py = height - (wy-origin_y)/res`) and a pan/zoom/follow canvas, and only draws robots on the *same* site+floor as the selected one because a pose from another floor is a different map frame. **Basemaps come from site bundles on the fleet box** (`--maps-dir`, default `$MOTE_FLEET_HOME/sites`, the layout `sites.py` writes, seeded by rsync until **M4** makes the registry canonical behind the same two routes). **M1's websockets blocker is settled**: `pixi run fleet-broker-ws` runs `eclipse-mosquitto` under docker with the repo's own `mosquitto.conf`, because conda-forge's build has none; `broker.sh` strips the WS stanza when the local binary cannot honour it and says so. Two things that run in the same file (`test_ui.py` → `ui_test.mjs`) are the MQTT codec and the transform, tested under node against the very files the browser loads; `browser_check.mjs` drives a real headless Chrome over CDP against a running stack and is an operator's tool, not a CI test. +## Releases and deployment + +**`pixi run sync` is the dev loop; released packages are the deployment mechanism** — full detail in **`docs/releasing.md`**, which is the file to read before touching anything under `release/`. The first-party packages are built by **`pixi-build-ros` straight from each `package.xml`** and published to the prefix.dev **`mote`** channel, so a robot runs an environment *resolved from a channel* with no checkout and no colcon. **One semver version for all first-party packages**, source of truth `[workspace] version` in `pixi.toml`, propagated by `release/version.py` into every `package.xml` (what the artifact carries) and `setup.py`; the per-package `pixi.toml` build manifests deliberately carry **no** `version`, so there is exactly one place to bump. Ten packages ship: the eight first-party ones plus **`sllidar_ros2` and `kinematic_icp`** — a robot installing from the channel has no source tree to build the submodules from, and they are built from **out-of-tree manifests** in `release/third_party/` because a `pixi.toml` inside a submodule would be an untracked file in someone else's repo. `mote_simulation` is **excluded by policy** (workstation-only; it builds fine). **`release-verify` is the load-bearing step**: it renders the robot deploy manifest against the freshly built local channel and *resolves* it, which is what catches a package that built but is uninstallable (it found `mote_fleet` depending on a nonexistent `ros-jazzy-python3-paho-mqtt`, the backend's fallback for a rosdep key RoboStack does not map — fixed with `extra-package-mappings`, as `scservo_linux` → `scservo-linux` is for `mote_hardware`). Two backend traps, both worked around and recorded: builds are always **`--clean`** because the incremental hash covers `package.xml` and sources but *not* `[package.build.config]`, so a mapping edit silently reuses a cached recipe; and the backend is pinned to **`pixi-build-ros 0.5.0.*`** because pixi 0.70.2 provides build API v4 while the 0.6.x backends need v5. **Publishing is never automatic** — CI builds and verifies both arches on a tag but does not upload; `pixi run release-publish` is a human action behind a typed confirmation. **A robot updates by version into slots** (`release/deploy/mote-update`: `stage` / `cutover` / `rollback` / `prune`) under `~/mote-deploy/`, with `current` flipped in one atomic rename and units pointed at the *symlink* so the next cutover redirects them without reinstalling; a cutover is stop-then-start (the Pi has neither the CPU for two stacks nor a second set of serial ports), health-gated, and rolls back by itself if the units do not come back. **`$MOTE_HOME` is outside the deploy root entirely**, so identity/maps/calibration cannot be touched by an update — asserted by `release/test/test_mote_update.py`, which runs a full stage→cutover→rollback→prune cycle against a stub pixi and checks `~/.mote` is byte-identical afterwards. `mote_bringup` therefore ships `udev/`, `systemd/`, `networkmanager/` and `tailscale/` into `share/` (its `setup.py`), because a checkout-free robot still has to run `pixi run setup`; `systemd/install.sh` honours a `MOTE_REPO` override and resolves `@DDS_CONFIG@` for either layout. + ## Sites (maps & zones) Everything that is only meaningful relative to one mapped place — the Nav2 map pair, the slam_toolbox posegraph, and named zones — lives together as a **site bundle** under `~/.mote/sites//floors//`, managed by `mote_bringup/sites.py` (CLI: `pixi run site`, docs in the module docstring). A floor is one SLAM session (one map frame); a site groups floors sharing a location. `~/.mote/active.yaml` selects the active site/floor per robot; launch files resolve the map (`nav2_launch.py`, `robot_launch.py`) and zones (`tasks_launch.py`) from it at launch time (zones fall back to the committed default). `MOTE_HOME` overrides `~/.mote` for tests/experiments. Map artifacts are immutable **revisions** under `floors//maps//`, published by atomically flipping the `floors//map` symlink once the revision is complete — a half-written save or interrupted transfer is never visible, and `site use-map ` rolls back. `save-map` stores the posegraph alongside the map so mapping can be *continued* in the same frame later (extend, don't remap — remapping breaks zone coordinates). Mapping runs also record the `mapping` rosbag stream by default (`mapping_launch.py record:=true`; the sim passes false), and `save-map` stamps the session's bag into the revision's `meta.yaml` for provenance (`site info` shows it). Zones are taught by driving there and running `pixi run save-zone `, not by editing YAML; a zone is a named pose (a fetch waypoint or a `goto ` target) that may optionally carry an area **footprint** — a taught `--radius` circle, or a `polygon` outline that follows the actual room walls — so it reads as a room and answers "am I in it"; one concept, one `zones.yaml`, `site info` shows the zone/footprint counts. Maps are saved as PNG (map_server reads it natively; browsers can render it directly). `save-map` automatically runs an FFT structure-extraction **cleaning pass** (`mote_bringup/map_cleanup`, `sites._promote_cleaned`): it keeps the untouched map_saver output as `map_raw.png` and promotes the decluttered image to the served `map.png` (plus a `diagnostics.png`), so navigation always consumes the cleaned map while the raw is retained for provenance/audit. The `map.yaml` frame is identical for both, so zones/localization are unaffected; a cleaning failure falls back to serving the raw. The posegraph belongs to the raw map — mapping continuation extends from raw, never the cleaned image. @@ -245,7 +257,7 @@ With multiple identical USB-serial adapters, pin by serial number — see commen ## Environment -pixi activates `install/setup.sh` and sets `RMW_IMPLEMENTATION=rmw_cyclonedds_cpp` automatically. Dependencies come from the `mote` prefix.dev channel, robostack-jazzy, and conda-forge. The default environment is what runs on the robot (a Raspberry Pi, deployed with `pixi run sync`); the `dev` feature adds `ros-jazzy-desktop` and the `rviz` task. +pixi activates `install/setup.sh` and sets `RMW_IMPLEMENTATION=rmw_cyclonedds_cpp` automatically. Dependencies come from the `mote` prefix.dev channel, robostack-jazzy, and conda-forge. The default environment is what runs on the robot (a Raspberry Pi); the `dev` feature adds `ros-jazzy-desktop` and the `rviz` task. A robot reached by `pixi run sync` runs this workspace from source — that is the dev loop. A *released* robot runs the generated deploy manifest instead (`release/deploy/pixi.toml`, see Releases above), which carries the same dependencies plus the released `ros-jazzy-mote-*` packages and a curated subset of the tasks. **DDS scoping.** The `sim` environment additionally sets `ROS_AUTOMATIC_DISCOVERY_RANGE=LOCALHOST`, so sims and benchmarks are invisible to the LAN and to each other's machines; `bench.py` claims a free `ROS_DOMAIN_ID` + `GZ_PARTITION` per invocation so two runs on one machine stay separate. Discovery visibility is one-way: a `LOCALHOST` participant still finds same-host default-range ones, but not vice-versa — hence `pixi run rviz-sim` (RViz joined to the sim's host-local graph) alongside `pixi run rviz` (default range, for the robot). The robot itself keeps LAN discovery and is tuned instead by `config/cyclonedds.xml` (systemd only). diff --git a/README.md b/README.md index 3664219..7a59698 100644 --- a/README.md +++ b/README.md @@ -225,25 +225,37 @@ The simulated lidar uses RPLIDAR C1 datasheet values from ### Deploying to the Pi -The above section assumes you are developing entirely on the Pi which is -definitely feasible at this stage. I do however want to support a more -"professional" workflow and so we need a way to develop on laptops and run on -the Pi. The exact mechanism that we will use is TBD. +The above section assumes you are developing entirely on the Pi, which is +definitely feasible at this stage. Developing on a laptop and running on the Pi +takes one of two paths, depending on whether you are iterating or shipping. -For now, I currently develop on a workstation and push to the Pi with rsync. The -`sync` task targets an SSH host named `mote` — change the host in the -[`pixi.toml`](pixi.toml) `[tasks]` `sync` entry to match your Pi, then: +**Iterating — rsync.** The `sync` task targets an SSH host named `mote` — change +the host in the [`pixi.toml`](pixi.toml) `[tasks]` `sync` entry to match your +Pi, then: ```bash pixi run sync # one-shot push pixi run sync-watch # keep pushing on every save (needs the dev env) ``` -For pushing a finished build to one or more robots, the direction is to publish -the first-party packages to the `prefix.dev/mote` channel (built with -[`pixi-build-ros`](https://pixi.prefix.dev/latest/build/ros/)) so a robot just -needs `pixi install` — no source checkout or compile on the bot. That work is in -progress. +Because the build uses `colcon --symlink-install`, edits to launch files, +`robot.yaml` and Python go live with no rebuild; only C++ needs `pixi run build`. + +**Shipping — versioned packages.** The first-party packages are published to the +`prefix.dev/mote` channel (built with +[`pixi-build-ros`](https://pixi.prefix.dev/latest/build/ros/)), so a robot runs +an environment resolved from that channel — no source checkout and no compile on +the bot. Updates are by version, and reversible: + +```bash +mote-update stage 0.2.0 # download and install; the robot keeps running +mote-update cutover 0.2.0 # stop, flip, restart, health-gate +mote-update rollback # back to the previous version +``` + +`~/.mote` — identity, maps, calibration — lives outside the deploy and survives +every update. See [`docs/releasing.md`](docs/releasing.md) for the version +scheme, how a release is cut, and the full robot update procedure. ## SO-101 Follower Arm diff --git a/docs/releasing.md b/docs/releasing.md new file mode 100644 index 0000000..861a9f5 --- /dev/null +++ b/docs/releasing.md @@ -0,0 +1,257 @@ +# Releasing Mote + +Mote's first-party packages are published as versioned conda packages to the +[`prefix.dev/mote`](https://prefix.dev/channels/mote) channel. A robot runs an +environment resolved from that channel — released packages, no source checkout, +no `colcon`. This document is how a release is cut, and how a robot is moved +onto one. + +## Two paths, and which to use + +| | Dev loop | Release deploy | +| --- | --- | --- | +| Command | `pixi run sync` / `sync-watch` | `mote-update update ` | +| Moves | your working tree | a pinned, versioned package set | +| Robot needs | a checkout + build toolchain | pixi, and nothing else | +| Good for | iterating on one robot you are sitting next to | every robot you are not | +| Reversible | `git checkout` and re-sync | `mote-update rollback` | + +`rsync` is still the right tool for the twenty-second edit-test loop, and +`--symlink-install` means launch/config/Python edits go live without a rebuild. +It is the wrong tool for *shipping*: it copies whatever your tree happens to +contain, to one host, with no version, no record, and no way back. Releases +replace it for deployment only. + +## The version + +**One semver version for all first-party packages**, bumped together. They are +co-developed and deployed as a set, so a robot runs "Mote 0.4.2" rather than a +combination of eight package versions that has to be reasoned about. + +`[workspace] version` in `pixi.toml` is the source of truth. It is propagated to +each package's `package.xml` (which is what the built artifact carries) and +`setup.py`: + +```bash +pixi run release-version show # what are we on +pixi run release-version set 0.2.0 # bump everywhere +pixi run release-version check # CI-friendly: do they agree +``` + +`check` runs as part of every release and in the release tests, because the one +failure mode worth automating away is publishing a package whose version does +not match the manifest that pins it. + +Tag the commit `v`. That tag matters at runtime: it is how a robot +fetches the deploy manifest for a version (below). + +## What is released + +Ten packages. Eight first-party, carrying the release version: + +`ros-jazzy-mote-description`, `-hardware`, `-nav`, `-bringup`, `-perception`, +`-tasks`, `-arm`, `-fleet` + +…and the two submodules under `third_party/`, at their own upstream versions: + +`ros-jazzy-sllidar-ros2`, `ros-jazzy-kinematic-icp` + +The submodules are not optional extras. A robot installing from the channel has +no source tree to build them from, and without them it has no lidar driver and +no odometry. They are built from **out-of-tree manifests** under +`release/third_party/`, because a `pixi.toml` added inside a submodule would be +an untracked file in someone else's repository, lost on the next clone. + +**`mote_simulation` is deliberately excluded.** It is workstation-only, is +already excluded from `pixi run sync`, and is developed and run from a checkout — +there is no robot that wants it. It builds correctly if that ever changes; the +exclusion is policy, not a limitation. + +## Cutting a release + +```bash +pixi run release-version set 0.2.0 +pixi run release # test -> manifest -> build -> verify +``` + +`release` is the whole dry run and publishes nothing: + +1. **`test`** — the existing colcon suite. +2. **`release-manifest`** — regenerates `release/deploy/pixi.toml`. +3. **`release-build`** — builds all ten packages into `dist/channel`, a local + *indexed* conda channel. +4. **`release-verify`** — renders the robot deploy manifest against + `dist/channel` and **resolves it**. This is the step with teeth: it proves + the release installs *as a set*, not merely that each package compiled. + +Commit the regenerated `release/deploy/pixi.toml` and `release/evidence/`. + +> `release-verify` is not ceremony. It is what caught `ros-jazzy-mote-fleet` +> declaring a dependency on `ros-jazzy-python3-paho-mqtt` — a package that does +> not exist, invented by the backend's fallback naming for a rosdep key +> RoboStack has no entry for. Every package built fine; the release was +> uninstallable. + +### Architectures + +Packages are **platform-specific** — nothing here is `noarch`, including the +Python ones — so each architecture is built natively: + +| Platform | Where | +| --- | --- | +| `linux-aarch64` | the Pi's architecture; CI's `ubuntu-24.04-arm` runner | +| `linux-64` | workstation and sim; CI's `ubuntu-latest` runner | + +Cross-compilation is not attempted. Native builds on both runners are cheaper +than making the C++ packages cross-compile, and CI already runs that matrix. +`release-verify` only checks the host architecture, because a local channel +holds only what was just built — the other half is verified by the same command +on the other runner. + +## Publishing + +The only outward-facing step, deliberately separate from `release` and never +part of it: + +```bash +pixi run release-publish +``` + +It asks you to type the version to confirm, then uploads to +`https://prefix.dev/mote`. Publishing is irreversible in the way that matters: a +version someone has resolved cannot be taken out of their lockfile. Needs +`pixi auth login prefix.dev` once. + +CI builds and verifies on both architectures but **does not publish** — the +upload is a human action. + +## Updating a robot + +A robot keeps versions in *slots* and points a `current` symlink at one: + +``` +~/mote-deploy/ + versions/0.1.0/ pixi.toml + pixi.lock + .pixi env + versions/0.2.0/ + current -> versions/0.2.0 + previous a file naming the rollback target +``` + +Two slots, not N: conda hard-links identical packages between environments, so a +point release costs disk only for what actually changed. + +`$MOTE_HOME` (default `~/.mote`) — identity, sites and maps, calibration, bags — +is **outside the deploy root entirely** and is never read or written by an +update. That is what makes updates routine and rollback safe, and it is asserted +by `release/test/test_mote_update.py`, which runs a full +stage → cutover → rollback → prune cycle and checks `~/.mote` comes out +byte-identical. + +### Bootstrapping a robot onto releases (once) + +```bash +# pixi, if it isn't there already +curl -fsSL https://pixi.sh/install.sh | bash + +mkdir -p ~/bin && curl -fsSL \ + https://raw.githubusercontent.com/ClachDev/Mote/v0.2.0/release/deploy/mote-update \ + -o ~/bin/mote-update && chmod +x ~/bin/mote-update + +mote-update update 0.2.0 + +# one-time host setup (udev rules, wifi power save, systemd units) +pixi run --manifest-path ~/mote-deploy/current/pixi.toml setup +``` + +### Routine updates + +```bash +mote-update status # what is installed, what is running +mote-update stage 0.3.0 # download and install; the robot keeps running +mote-update cutover 0.3.0 # stop, flip, restart, health-gate +mote-update rollback # back to the previous slot +mote-update prune # drop everything but current + previous +``` + +`update` is `stage` then `cutover`. They are separate verbs on purpose: staging +is safe at any time and is the slow part, cutover is the brief outage. + +**Cut over when the robot is idle, never mid-mission.** The Pi has neither the +CPU for two ROS stacks nor a second set of serial ports, so this is +stop-then-start, not a hot swap. "Install alongside" means two environments on +*disk*; only one ever runs. + +A cutover: + +1. Stops whichever of `mote-bringup`, `mote-health`, `mote-agent` were running — + and only those, so a robot whose units are installed-but-not-enabled (the + default) is not silently switched to autostart. +2. Flips `current` in a single `rename`, so it is never missing or dangling. +3. Reinstalls the systemd units from the new slot, pointed at the `current` + symlink rather than at a slot — so the *next* cutover redirects them without + reinstalling. +4. Waits for the units to come back (`--health-timeout`, default 90s) and + **rolls back automatically** if they do not. + +Rollback itself is not health-gated: it is the recovery path, and failing it +would leave nowhere to go. It reports and lets you look. + +### Where the manifest comes from + +`mote-update` needs the deploy manifest for the version. It resolves, in order: + +1. `--manifest ` (offline, or testing an unreleased build) +2. `$MOTE_DEPLOY_MANIFEST` +3. `https://raw.githubusercontent.com/ClachDev/Mote/v/release/deploy/pixi.toml` + +The third is why the tag matters, and why the flow is headless: a fleet +orchestrator needs nothing but a version string. This is the OTA foundation the +fleet design assumes (`docs/design/fleet.md` §6). + +The manifest's **dependencies are generated** from the workspace `pixi.toml` at +release time — a robot resolving a different set than the release was tested +against is not running the release. Its **tasks are hand-written** in +`release/deploy/pixi.toml.in`, because the deployed task surface is a curated +subset: no `build`, no `sync`, no sim. Task *names* match the workspace's, so a +runbook or a systemd unit works the same on a checkout and on a deploy — there +is a test for that. + +## Notes on the build backend + +- **`pixi-build` is a preview feature**, so every released package pins its + backend in a small `pixi.toml` next to its `package.xml`. An unpinned backend + is a moving target, and the backend has changed its config schema between + minor versions. +- **The pin is `pixi-build-ros 0.5.0.*`**, because pixi 0.70.2 provides build + API v4 and the 0.6.x backends require v5. Moving to 0.6.x means requiring a + newer pixi; the payoff is a nicer `extra-package-mappings` syntax (an inline + map rather than the list-of-tables the 0.5.x backend wants). +- **Builds are always `--clean`.** The backend's incremental-build hash covers + `package.xml` and the sources but *not* `[package.build.config]`, so editing a + dependency mapping and rebuilding silently reuses the cached recipe. That is a + reproducibility hole, so release builds never reuse a build directory. (This + cost an hour of confusion; it is why the note is here.) +- **Dependency names come from `package.xml` through RoboStack's table**, and + anything unknown falls back to `ros-jazzy-` — which is wrong for every + non-ROS conda dependency. Those get an `extra-package-mappings` entry in the + package's build manifest: `scservo_linux` → `scservo-linux` for + `mote_hardware`, `python3-paho-mqtt` → `paho-mqtt` for `mote_fleet`. The + mapping key must match the `package.xml` dependency string exactly. +- **`kinematic_icp` needs `EXTRA_CMAKE_ARGS=-DCMAKE_POLICY_VERSION_MINIMUM=3.5`** + (its vendored tessil declares a pre-3.5 `cmake_minimum_required`) and pulls + sources via CMake `FetchContent` at build time, so its build is not hermetic. +- **Tests do not run during a package build** (`BUILD_TESTING=OFF`). They run in + `pixi run test` before anything is built, which is why `release` depends on + `test`. + +## Testing the release tooling + +```bash +pixi run test-release +``` + +A ROS-free env, so it is fast. It checks that no first-party package is missing +from the release set, that backends stay pinned, that no build manifest +duplicates the version, that the committed deploy manifest is not stale, that +every task the systemd units invoke exists in a deploy, and that an update +cannot touch `~/.mote`. diff --git a/mote_arm/package.xml b/mote_arm/package.xml index 840fb64..9107b8d 100644 --- a/mote_arm/package.xml +++ b/mote_arm/package.xml @@ -2,7 +2,7 @@ mote_arm - 0.0.0 + 0.1.0 SO-101 follower arm bring-up: joint states, safe jog control, and bench tools Michael Johnson Apache-2.0 diff --git a/mote_arm/pixi.toml b/mote_arm/pixi.toml new file mode 100644 index 0000000..f87c72c --- /dev/null +++ b/mote_arm/pixi.toml @@ -0,0 +1,8 @@ +# Release build definition (docs/releasing.md). Deliberately carries no +# `version`: the backend reads it from package.xml, which stays the single +# source of truth that `pixi run release-version` bumps. +[package.build] +backend = { name = "pixi-build-ros", version = "0.5.0.*" } + +[package.build.config] +distro = "jazzy" diff --git a/mote_arm/setup.py b/mote_arm/setup.py index e778253..9c2c507 100644 --- a/mote_arm/setup.py +++ b/mote_arm/setup.py @@ -7,7 +7,7 @@ setup( name=package_name, - version="0.0.0", + version="0.1.0", packages=find_packages(exclude=["test"]), data_files=[ ("share/ament_index/resource_index/packages", ["resource/" + package_name]), diff --git a/mote_bringup/package.xml b/mote_bringup/package.xml index 5775975..0b58be2 100644 --- a/mote_bringup/package.xml +++ b/mote_bringup/package.xml @@ -2,7 +2,7 @@ mote_bringup - 0.0.0 + 0.1.0 Launch files for the mote Michael Johnson Apache-2.0 diff --git a/mote_bringup/pixi.toml b/mote_bringup/pixi.toml new file mode 100644 index 0000000..f87c72c --- /dev/null +++ b/mote_bringup/pixi.toml @@ -0,0 +1,8 @@ +# Release build definition (docs/releasing.md). Deliberately carries no +# `version`: the backend reads it from package.xml, which stays the single +# source of truth that `pixi run release-version` bumps. +[package.build] +backend = { name = "pixi-build-ros", version = "0.5.0.*" } + +[package.build.config] +distro = "jazzy" diff --git a/mote_bringup/setup.py b/mote_bringup/setup.py index c1b1120..42d50af 100644 --- a/mote_bringup/setup.py +++ b/mote_bringup/setup.py @@ -7,7 +7,7 @@ setup( name=package_name, - version="0.0.0", + version="0.1.0", packages=find_packages(exclude=["test"]), data_files=[ ("share/ament_index/resource_index/packages", ["resource/" + package_name]), @@ -18,6 +18,23 @@ os.path.join("share", package_name, "provisioning"), glob("provisioning/*"), ), + # One-time host-setup assets. A robot running a released version has no + # checkout to run these from, so they ship inside the package and the + # deploy manifest's `setup` tasks resolve them under $CONDA_PREFIX/share + # (docs/releasing.md). Without these, `pixi run setup` is a + # checkout-only command and a released robot could never install its + # udev rules or systemd units. + ( + os.path.join("share", package_name, "systemd"), + glob("systemd/*.service") + + ["systemd/install.sh", "systemd/journald-mote.conf"], + ), + (os.path.join("share", package_name, "udev"), glob("udev/*.rules")), + ( + os.path.join("share", package_name, "networkmanager"), + glob("networkmanager/*"), + ), + (os.path.join("share", package_name, "tailscale"), glob("tailscale/*.sh")), ], install_requires=["setuptools"], extras_require={"test": ["pytest"]}, diff --git a/mote_bringup/systemd/install.sh b/mote_bringup/systemd/install.sh index 54d0021..4bd8a24 100755 --- a/mote_bringup/systemd/install.sh +++ b/mote_bringup/systemd/install.sh @@ -1,21 +1,49 @@ #!/usr/bin/env bash # Install the mote systemd services for the invoking user. -# Run via: pixi run install-systemd (uses sudo; @USER@/@HOME@/@REPO@/@DDS_IFACE@ -# are filled in here). Override the DDS interface with: +# Run via: pixi run install-systemd (uses sudo; @USER@/@HOME@/@REPO@/@DDS_CONFIG@ +# /@DDS_IFACE@ are filled in here). Override the DDS interface with: # MOTE_DDS_INTERFACE=eth0 pixi run install-systemd +# Set MOTE_REPO to install units for a released deploy slot rather than for the +# checkout this script lives in (see below, and docs/releasing.md). set -euo pipefail MOTE_USER="${SUDO_USER:-$USER}" MOTE_HOME="$(getent passwd "$MOTE_USER" | cut -d: -f6)" SRC_DIR="$(cd "$(dirname "$0")" && pwd)" -# The checkout the units should run from: this script's own repo root, not a -# hardcoded ~/Mote. Installing from a second checkout (a git worktree, a staging -# clone) otherwise produces units pointing at a tree that need not contain the -# tasks they invoke, which fails ExecStartPre with status=127 and leaves the -# service restarting forever. -MOTE_REPO="$(cd "$SRC_DIR/../.." && pwd)" +# The directory the units run from: the one holding the pixi manifest whose +# tasks they invoke. Two layouts: +# +# source checkout -- this script's own repo root, not a hardcoded ~/Mote. +# Installing from a second checkout (a git worktree, a staging clone) +# otherwise produces units pointing at a tree that need not contain the +# tasks they invoke, which fails ExecStartPre with status=127 and leaves +# the service restarting forever. +# +# released deploy -- the slot directory, which cannot be derived from SRC_DIR +# because this script then lives inside the *environment*, under +# share/mote_bringup. mote-update passes it in as MOTE_REPO, pointing at +# the stable `current` symlink. +if [ -n "${MOTE_REPO:-}" ]; then + MOTE_DEPLOYED=1 + MOTE_REPO="$(cd "$MOTE_REPO" && pwd)" +else + MOTE_DEPLOYED=0 + MOTE_REPO="$(cd "$SRC_DIR/../.." && pwd)" +fi echo "Repo: $MOTE_REPO" +# Where the units read cyclonedds.xml from. Deliberately expressed through +# $MOTE_REPO rather than as "$SRC_DIR/../config": in a deploy that keeps the +# path routed through the `current` symlink, so a cutover redirects every unit +# to the new slot's config instead of pinning them to the slot that happened to +# install them. +if [ "$MOTE_DEPLOYED" = "1" ]; then + DDS_CONFIG="$MOTE_REPO/.pixi/envs/default/share/mote_bringup/config/cyclonedds.xml" +else + DDS_CONFIG="$MOTE_REPO/mote_bringup/config/cyclonedds.xml" +fi +echo "DDS config: $DDS_CONFIG" + # The interface the ROS graph should live on: the one carrying the default # route. cyclonedds.xml treats it as optional, so a wrong guess degrades to a # loopback-only graph rather than stopping the robot from booting. @@ -25,7 +53,7 @@ echo "DDS interface: $DDS_IFACE" for unit in "$SRC_DIR"/*.service; do sed "s|@USER@|$MOTE_USER|g; s|@HOME@|$MOTE_HOME|g; s|@REPO@|$MOTE_REPO|g; \ - s|@DDS_IFACE@|$DDS_IFACE|g" "$unit" \ + s|@DDS_CONFIG@|$DDS_CONFIG|g; s|@DDS_IFACE@|$DDS_IFACE|g" "$unit" \ | sudo tee "/etc/systemd/system/$(basename "$unit")" > /dev/null done diff --git a/mote_bringup/systemd/mote-agent.service b/mote_bringup/systemd/mote-agent.service index 2f74958..df314c9 100644 --- a/mote_bringup/systemd/mote-agent.service +++ b/mote_bringup/systemd/mote-agent.service @@ -23,8 +23,10 @@ RestartSteps=5 RestartMaxDelaySec=30 # Pin DDS to the robot's one real interface and drop non-discovery multicast -# (mote_bringup/config/cyclonedds.xml); @DDS_IFACE@ is filled in by install.sh. -Environment=CYCLONEDDS_URI=file://@REPO@/mote_bringup/config/cyclonedds.xml +# (mote_bringup/config/cyclonedds.xml); @DDS_CONFIG@ and @DDS_IFACE@ are filled +# in by install.sh, which resolves the config path for a source checkout or a +# released deploy slot. +Environment=CYCLONEDDS_URI=file://@DDS_CONFIG@ Environment=MOTE_DDS_INTERFACE=@DDS_IFACE@ [Install] diff --git a/mote_bringup/systemd/mote-bringup.service b/mote_bringup/systemd/mote-bringup.service index 3b43503..8c248d7 100644 --- a/mote_bringup/systemd/mote-bringup.service +++ b/mote_bringup/systemd/mote-bringup.service @@ -35,8 +35,10 @@ RestartMaxDelaySec=30 TimeoutStartSec=120 # Pin DDS to the robot's one real interface and drop non-discovery multicast -# (mote_bringup/config/cyclonedds.xml); @DDS_IFACE@ is filled in by install.sh. -Environment=CYCLONEDDS_URI=file://@REPO@/mote_bringup/config/cyclonedds.xml +# (mote_bringup/config/cyclonedds.xml); @DDS_CONFIG@ and @DDS_IFACE@ are filled +# in by install.sh, which resolves the config path for a source checkout or a +# released deploy slot. +Environment=CYCLONEDDS_URI=file://@DDS_CONFIG@ Environment=MOTE_DDS_INTERFACE=@DDS_IFACE@ [Install] diff --git a/mote_bringup/systemd/mote-health.service b/mote_bringup/systemd/mote-health.service index 2878922..0eee4f8 100644 --- a/mote_bringup/systemd/mote-health.service +++ b/mote_bringup/systemd/mote-health.service @@ -34,8 +34,10 @@ RestartSteps=5 RestartMaxDelaySec=30 # Pin DDS to the robot's one real interface and drop non-discovery multicast -# (mote_bringup/config/cyclonedds.xml); @DDS_IFACE@ is filled in by install.sh. -Environment=CYCLONEDDS_URI=file://@REPO@/mote_bringup/config/cyclonedds.xml +# (mote_bringup/config/cyclonedds.xml); @DDS_CONFIG@ and @DDS_IFACE@ are filled +# in by install.sh, which resolves the config path for a source checkout or a +# released deploy slot. +Environment=CYCLONEDDS_URI=file://@DDS_CONFIG@ Environment=MOTE_DDS_INTERFACE=@DDS_IFACE@ [Install] diff --git a/mote_bringup/systemd/mote-nav.service b/mote_bringup/systemd/mote-nav.service index d432475..83d356a 100644 --- a/mote_bringup/systemd/mote-nav.service +++ b/mote_bringup/systemd/mote-nav.service @@ -18,8 +18,10 @@ RestartSteps=5 RestartMaxDelaySec=30 # Pin DDS to the robot's one real interface and drop non-discovery multicast -# (mote_bringup/config/cyclonedds.xml); @DDS_IFACE@ is filled in by install.sh. -Environment=CYCLONEDDS_URI=file://@REPO@/mote_bringup/config/cyclonedds.xml +# (mote_bringup/config/cyclonedds.xml); @DDS_CONFIG@ and @DDS_IFACE@ are filled +# in by install.sh, which resolves the config path for a source checkout or a +# released deploy slot. +Environment=CYCLONEDDS_URI=file://@DDS_CONFIG@ Environment=MOTE_DDS_INTERFACE=@DDS_IFACE@ [Install] diff --git a/mote_bringup/systemd/mote-record.service b/mote_bringup/systemd/mote-record.service index f85c6b1..ad7b524 100644 --- a/mote_bringup/systemd/mote-record.service +++ b/mote_bringup/systemd/mote-record.service @@ -23,8 +23,10 @@ RestartSteps=5 RestartMaxDelaySec=30 # Pin DDS to the robot's one real interface and drop non-discovery multicast -# (mote_bringup/config/cyclonedds.xml); @DDS_IFACE@ is filled in by install.sh. -Environment=CYCLONEDDS_URI=file://@REPO@/mote_bringup/config/cyclonedds.xml +# (mote_bringup/config/cyclonedds.xml); @DDS_CONFIG@ and @DDS_IFACE@ are filled +# in by install.sh, which resolves the config path for a source checkout or a +# released deploy slot. +Environment=CYCLONEDDS_URI=file://@DDS_CONFIG@ Environment=MOTE_DDS_INTERFACE=@DDS_IFACE@ [Install] diff --git a/mote_bringup/systemd/mote-slam.service b/mote_bringup/systemd/mote-slam.service index 115a229..cf02805 100644 --- a/mote_bringup/systemd/mote-slam.service +++ b/mote_bringup/systemd/mote-slam.service @@ -21,8 +21,10 @@ RestartSteps=5 RestartMaxDelaySec=30 # Pin DDS to the robot's one real interface and drop non-discovery multicast -# (mote_bringup/config/cyclonedds.xml); @DDS_IFACE@ is filled in by install.sh. -Environment=CYCLONEDDS_URI=file://@REPO@/mote_bringup/config/cyclonedds.xml +# (mote_bringup/config/cyclonedds.xml); @DDS_CONFIG@ and @DDS_IFACE@ are filled +# in by install.sh, which resolves the config path for a source checkout or a +# released deploy slot. +Environment=CYCLONEDDS_URI=file://@DDS_CONFIG@ Environment=MOTE_DDS_INTERFACE=@DDS_IFACE@ [Install] diff --git a/mote_description/package.xml b/mote_description/package.xml index 3f7330e..2739f74 100644 --- a/mote_description/package.xml +++ b/mote_description/package.xml @@ -2,7 +2,7 @@ mote_description - 0.0.0 + 0.1.0 URDF/XACRO robot description for Mote differential-drive mobile robot Michael Johnson Apache-2.0 diff --git a/mote_description/pixi.toml b/mote_description/pixi.toml new file mode 100644 index 0000000..f87c72c --- /dev/null +++ b/mote_description/pixi.toml @@ -0,0 +1,8 @@ +# Release build definition (docs/releasing.md). Deliberately carries no +# `version`: the backend reads it from package.xml, which stays the single +# source of truth that `pixi run release-version` bumps. +[package.build] +backend = { name = "pixi-build-ros", version = "0.5.0.*" } + +[package.build.config] +distro = "jazzy" diff --git a/mote_fleet/package.xml b/mote_fleet/package.xml index f200ea2..bd5a982 100644 --- a/mote_fleet/package.xml +++ b/mote_fleet/package.xml @@ -2,7 +2,7 @@ mote_fleet - 0.0.0 + 0.1.0 Fleet control plane: the robot's agent, and the off-board enrollment/registry server Michael Johnson Apache-2.0 diff --git a/mote_fleet/pixi.toml b/mote_fleet/pixi.toml new file mode 100644 index 0000000..5092749 --- /dev/null +++ b/mote_fleet/pixi.toml @@ -0,0 +1,16 @@ +# Release build definition (docs/releasing.md). Deliberately carries no +# `version`: the backend reads it from package.xml, which stays the single +# source of truth that `pixi run release-version` bumps. +[package.build] +backend = { name = "pixi-build-ros", version = "0.5.0.*" } + +[package.build.config] +distro = "jazzy" +# paho-mqtt is a plain conda-forge package, not a ROS one, and RoboStack has no +# entry for the rosdep key -- so the backend's `ros-jazzy-` fallback would make +# the built package depend on something that does not exist. Caught by +# `pixi run release-verify`, which is the reason that step resolves a whole +# robot environment rather than trusting a successful build. +extra-package-mappings = [ + { "python3-paho-mqtt" = { robostack = ["paho-mqtt"] } }, +] diff --git a/mote_fleet/setup.py b/mote_fleet/setup.py index f168654..2e33c25 100644 --- a/mote_fleet/setup.py +++ b/mote_fleet/setup.py @@ -7,7 +7,7 @@ setup( name=package_name, - version="0.0.0", + version="0.1.0", packages=find_packages(exclude=["test"]), data_files=[ ("share/ament_index/resource_index/packages", ["resource/" + package_name]), diff --git a/mote_hardware/package.xml b/mote_hardware/package.xml index dbe6ee6..dcf1967 100644 --- a/mote_hardware/package.xml +++ b/mote_hardware/package.xml @@ -2,7 +2,7 @@ mote_hardware - 0.0.0 + 0.1.0 ROS2 hardware interface for Mote differential-drive robot (motors and servo control) Michael Johnson Apache-2.0 @@ -13,6 +13,10 @@ rclcpp_lifecycle hardware_interface pluginlib + + scservo_linux ament_cmake_gtest ament_lint_auto diff --git a/mote_hardware/pixi.toml b/mote_hardware/pixi.toml new file mode 100644 index 0000000..517cbf7 --- /dev/null +++ b/mote_hardware/pixi.toml @@ -0,0 +1,11 @@ +[package.build] +backend = { name = "pixi-build-ros", version = "0.5.0.*" } + +[package.build.config] +distro = "jazzy" +# The Feetech SCServo C++ SDK is a plain conda package on the mote channel, not +# a ROS one, so the backend's `ros-jazzy-` fallback would name a package that +# does not exist. +extra-package-mappings = [ + { scservo_linux = { robostack = ["scservo-linux"] } }, +] diff --git a/mote_nav/package.xml b/mote_nav/package.xml index fc61c51..1bacf8c 100644 --- a/mote_nav/package.xml +++ b/mote_nav/package.xml @@ -2,7 +2,7 @@ mote_nav - 0.0.0 + 0.1.0 Nav2 plugins for the Mote differential-drive robot Michael Johnson Apache-2.0 diff --git a/mote_nav/pixi.toml b/mote_nav/pixi.toml new file mode 100644 index 0000000..f87c72c --- /dev/null +++ b/mote_nav/pixi.toml @@ -0,0 +1,8 @@ +# Release build definition (docs/releasing.md). Deliberately carries no +# `version`: the backend reads it from package.xml, which stays the single +# source of truth that `pixi run release-version` bumps. +[package.build] +backend = { name = "pixi-build-ros", version = "0.5.0.*" } + +[package.build.config] +distro = "jazzy" diff --git a/mote_perception/package.xml b/mote_perception/package.xml index 014ac06..1a59bb2 100644 --- a/mote_perception/package.xml +++ b/mote_perception/package.xml @@ -2,7 +2,7 @@ mote_perception - 0.0.0 + 0.1.0 Camera-derived perception nodes for the mote Michael Johnson Apache-2.0 diff --git a/mote_perception/pixi.toml b/mote_perception/pixi.toml new file mode 100644 index 0000000..f87c72c --- /dev/null +++ b/mote_perception/pixi.toml @@ -0,0 +1,8 @@ +# Release build definition (docs/releasing.md). Deliberately carries no +# `version`: the backend reads it from package.xml, which stays the single +# source of truth that `pixi run release-version` bumps. +[package.build] +backend = { name = "pixi-build-ros", version = "0.5.0.*" } + +[package.build.config] +distro = "jazzy" diff --git a/mote_perception/setup.py b/mote_perception/setup.py index 5b46ac4..6c0add2 100644 --- a/mote_perception/setup.py +++ b/mote_perception/setup.py @@ -7,13 +7,19 @@ setup( name=package_name, - version="0.0.0", + version="0.1.0", packages=find_packages(exclude=["test"]), data_files=[ ("share/ament_index/resource_index/packages", ["resource/" + package_name]), ("share/" + package_name, ["package.xml"]), (os.path.join("share", package_name, "launch"), glob("launch/*.py")), (os.path.join("share", package_name, "config"), glob("config/*")), + # The robot-side inference diagnostics (inference_health, inference_bench) + # are run against the inference machine from the robot, which under a + # released deploy has no checkout to run them from. The whole tools/ dir + # ships because it is ~128 KB of Python and splitting it would mean + # maintaining a list of which harness is robot-side. + (os.path.join("share", package_name, "tools"), glob("tools/*.py")), ], install_requires=["setuptools"], # the 'test' extra tells colcon to run these tests with pytest diff --git a/mote_tasks/package.xml b/mote_tasks/package.xml index f1cc36e..5730c58 100644 --- a/mote_tasks/package.xml +++ b/mote_tasks/package.xml @@ -2,7 +2,7 @@ mote_tasks - 0.0.0 + 0.1.0 Behaviour-tree task layer that drives Nav2 to run missions Michael Johnson Apache-2.0 diff --git a/mote_tasks/pixi.toml b/mote_tasks/pixi.toml new file mode 100644 index 0000000..f87c72c --- /dev/null +++ b/mote_tasks/pixi.toml @@ -0,0 +1,8 @@ +# Release build definition (docs/releasing.md). Deliberately carries no +# `version`: the backend reads it from package.xml, which stays the single +# source of truth that `pixi run release-version` bumps. +[package.build] +backend = { name = "pixi-build-ros", version = "0.5.0.*" } + +[package.build.config] +distro = "jazzy" diff --git a/mote_tasks/setup.py b/mote_tasks/setup.py index 5aa5297..b2383a4 100644 --- a/mote_tasks/setup.py +++ b/mote_tasks/setup.py @@ -7,7 +7,7 @@ setup( name=package_name, - version="0.0.0", + version="0.1.0", packages=find_packages(exclude=["test"]), data_files=[ ("share/ament_index/resource_index/packages", ["resource/" + package_name]), diff --git a/pixi.lock b/pixi.lock index 2abb958..4c7ef71 100644 --- a/pixi.lock +++ b/pixi.lock @@ -4058,6 +4058,76 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/virtualenv-21.4.3-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.0-pyhcf101f3_0.conda + release: + channels: + - url: https://prefix.dev/mote/ + - url: https://conda.anaconda.org/robostack-jazzy/ + - url: https://conda.anaconda.org/conda-forge/ + packages: + linux-64: + - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_9.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/icu-78.3-h54a6638_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.46.1-default_hbd61a6d_102.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.8.1-hecca717_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h3435931_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.2.0-he0feb66_20.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.2.0-he0feb66_20.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.3-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libmpdec-4.0.0-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.53.4-hf4e2dac_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.2.0-h934c35e_20.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.42.2-h5347b49_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.3-h35e630c_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.14.6-habeac84_101_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-h853b02a_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_hd70dff1_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.7.22-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/iniconfig-2.3.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.2-pyhc364b38_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pygments-2.20.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pytest-8.4.2-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-2.4.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.16.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda + linux-aarch64: + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/_openmp_mutex-4.5-20_gnu.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/bzip2-1.0.8-h4777abc_9.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/ld_impl_linux-aarch64-2.46.1-default_h1979696_102.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libexpat-2.8.1-hfae3067_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libffi-3.5.2-h376a255_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libgcc-15.2.0-h8acb6b2_20.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libgomp-15.2.0-h8acb6b2_20.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/liblzma-5.8.3-he30d5cf_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libmpdec-4.0.0-he30d5cf_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libsqlite-3.53.4-h022381a_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libuuid-2.42.2-h1022ec0_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libzlib-1.3.2-hdc9db2a_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/ncurses-6.6-hf8d1292_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/openssl-3.6.3-h546c87b_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/python-3.14.6-hc679e19_101_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/readline-8.3-hb682ff5_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/tk-8.6.13-noxft_h5cf4473_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/zstd-1.5.7-h85ac4a6_6.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.7.22-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/iniconfig-2.3.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.2-pyhc364b38_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pygments-2.20.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pytest-8.4.2-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-2.4.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.16.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda sim: channels: - url: https://prefix.dev/mote/ diff --git a/pixi.toml b/pixi.toml index 5a71669..fb95737 100644 --- a/pixi.toml +++ b/pixi.toml @@ -4,6 +4,10 @@ channels = ["https://prefix.dev/mote", "robostack-jazzy", "conda-forge"] name = "mote" platforms = ["linux-64", "linux-aarch64"] version = "0.1.0" +# `pixi publish` builds the first-party packages as conda packages for the +# `mote` channel (docs/releasing.md). Still a pixi preview feature, so the +# backend version is pinned in each package's build manifest. +preview = ["pixi-build"] [tasks] submodules = "git submodule update --init" @@ -14,6 +18,25 @@ test = { cmd = "colcon test --packages-select mote_hardware mote_bringup mote_na "build", ] } +# Releases: versioned conda packages on the prefix.dev `mote` channel, which is +# how software reaches a robot (docs/releasing.md). `pixi run sync` below stays +# the *development* loop; this is the deployment one. +release-version = "python release/version.py" # show | check | set X.Y.Z +release-build = "python release/build.py" # -> dist/channel (local) +release-verify = "python release/verify.py" # resolve a robot env against it +release-manifest = "python release/deploy_manifest.py" # regenerate release/deploy/pixi.toml +# The whole dry run: versions agree, tests pass, every package builds, and a +# robot environment resolves against the result. Publishes nothing. +release = { depends-on = [ + "test", + "release-manifest", + "release-build", + "release-verify", +] } +# The one outward-facing step, kept separate and never part of `release`: it +# uploads to a public channel and asks for typed confirmation first. +release-publish = "python release/build.py --to https://prefix.dev/mote" + # Pi Setup tasks setup-ids = "ros2 run mote_hardware setup_ids" # SO-101 follower arm bring-up (see mote_arm/README.md, mote_arm/BENCH.md). @@ -308,6 +331,19 @@ fleetctl = "python -u mote_fleet/server/fleetctl.py" # end-to-end run (see mote_fleet/test/test_e2e_fleet.py). test-fleet = "pytest mote_fleet/test -q" +# The release tooling's own tests. Its own minimal env (no ROS) for the same +# reason `lint` has one: these tests read manifests and scripts, so making them +# wait on the robot stack to solve would be pure cost. Auto-selected, because +# the task is defined only here. +[feature.release.dependencies] +pytest = ">=8,<9" + +[feature.release.tasks] +# Guards the release tooling: that no package is missing from the release set, +# that build backends stay pinned, that the committed deploy manifest is +# current, and that an update cannot touch ~/.mote. +test-release = "pytest release/test -q" + [feature.lint.dependencies] pre-commit = ">=4,<5" @@ -333,3 +369,5 @@ inference-rocm = { features = ["inference-rocm"], no-default-feature = true } # can never perturb any other env. # Minimal env (no ROS) so 'pixi run lint' is fast to solve and install lint = { features = ["lint"], no-default-feature = true } +# Likewise for the release tooling's tests -- they read manifests, not ROS. +release = { features = ["release"], no-default-feature = true } diff --git a/release/build.py b/release/build.py new file mode 100755 index 0000000..9f9f1c6 --- /dev/null +++ b/release/build.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 +"""Build every released package and put it in a channel. + +One command for both destinations, because they must build *identically*: a +dry-run that exercises a different code path than the real publish is not a +dry-run. The only difference is where the artifacts land -- + + --to dist/channel a local, indexed file:// channel (default) + --to https://prefix.dev/mote the real thing (interactive confirmation) + +A local channel rather than a plain output directory is the point of the +dry-run: an indexed channel can actually be *resolved against*, so +``release/verify.py`` proves the packages install as a set before any of them +is uploaded. A directory of .conda files proves only that rattler-build exited +zero. + +Packages are built for the host platform. Cross-compiling the C++ packages is +not attempted: the release matrix builds each architecture natively instead +(see docs/releasing.md), which is what CI's linux-64 + linux-aarch64 runners are +for. + +Usage: + release/build.py # -> dist/channel + release/build.py --to dist/channel + release/build.py --to https://prefix.dev/mote --yes +""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from version import REPO_ROOT, current_version # noqa: E402 + +# Every package published to the `mote` channel, in dependency order (which is +# cosmetic -- each build resolves its dependencies from the channels, not from +# its siblings -- but makes the log read sensibly). +# +# The value is the manifest pixi builds from. First-party packages carry their +# own pixi.toml next to package.xml; the two submodules cannot (a manifest added +# inside a submodule is an untracked file in someone else's repo), so they get +# an out-of-tree definition under release/third_party that points back at the +# submodule with `source.path`. +# +# mote_simulation is deliberately absent: it is workstation-only, is already +# excluded from `pixi run sync`, and is developed and run from a checkout. It +# builds fine if that ever changes -- the exclusion is policy, not capability. +RELEASE_SET = [ + ("ros-jazzy-mote-description", "mote_description"), + ("ros-jazzy-mote-hardware", "mote_hardware"), + ("ros-jazzy-mote-nav", "mote_nav"), + ("ros-jazzy-mote-bringup", "mote_bringup"), + ("ros-jazzy-mote-perception", "mote_perception"), + ("ros-jazzy-mote-tasks", "mote_tasks"), + ("ros-jazzy-mote-arm", "mote_arm"), + ("ros-jazzy-mote-fleet", "mote_fleet"), + # Third-party ROS packages carried as submodules. They are built and + # published too, because a robot installing from the channel has no source + # tree to colcon-build them from -- without these, a released robot has no + # lidar driver and no odometry. + ("ros-jazzy-sllidar-ros2", "release/third_party/sllidar_ros2"), + ("ros-jazzy-kinematic-icp", "release/third_party/kinematic_icp"), +] + +DEFAULT_CHANNEL = "dist/channel" +MOTE_CHANNEL = "https://prefix.dev/mote" + + +def is_local(channel: str) -> bool: + return "://" not in channel or channel.startswith("file://") + + +def confirm_remote_publish(channel: str, version: str) -> None: + """Gate an outward-facing upload behind a typed confirmation. + + Publishing to prefix.dev is irreversible in the way that matters: a version + that has been downloaded cannot be un-published from someone's lockfile. The + prompt is deliberately not a y/n. + """ + print(f"\nAbout to publish Mote {version} to {channel}") + print(f" {len(RELEASE_SET)} packages, host platform only.") + print(" This is public and cannot be taken back once anyone has resolved it.") + reply = input(f"\nType the version ({version}) to continue: ").strip() + if reply != version: + raise SystemExit("aborted -- nothing was published") + + +def build_one(manifest: Path, channel: str, platform: str | None) -> None: + # --clean is not optional here. The backend's incremental-build hash covers + # package.xml and the sources but NOT [package.build.config], so editing a + # dependency mapping and rebuilding silently reuses the cached recipe and + # produces an artifact that does not match the manifest. A release that can + # do that is not reproducible, so every release build starts from scratch. + command = ["pixi", "publish", "--clean", "--path", str(manifest), "--to", channel] + if platform: + command += ["--target-platform", platform] + subprocess.run(command, cwd=REPO_ROOT, check=True) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + parser.add_argument( + "--to", + default=DEFAULT_CHANNEL, + help=f"destination channel (default: {DEFAULT_CHANNEL}, a local file:// channel)", + ) + parser.add_argument( + "--target-platform", + default=None, + help="platform to build for (default: the host; see docs/releasing.md on arch builds)", + ) + parser.add_argument( + "--yes", + action="store_true", + help="skip the confirmation prompt for a remote channel (for CI)", + ) + args = parser.parse_args() + + version = current_version() + if subprocess.run( + [sys.executable, str(Path(__file__).parent / "version.py"), "check"], + cwd=REPO_ROOT, + ).returncode: + return 1 + + channel = args.to + if is_local(channel): + # Resolve a relative local channel against the repo root so the command + # behaves the same from any working directory. + path = Path(channel.removeprefix("file://")) + if not path.is_absolute(): + path = REPO_ROOT / path + path.mkdir(parents=True, exist_ok=True) + channel = f"file://{path}" + elif not args.yes: + confirm_remote_publish(channel, version) + + print(f"\nBuilding Mote {version} -> {channel}\n") + for name, manifest in RELEASE_SET: + print(f"--- {name} ({manifest})") + build_one(REPO_ROOT / manifest, channel, args.target_platform) + + # The submodule packages keep their own upstream versions, so only the + # first-party set carries the release version. Re-publishing an unchanged + # submodule build is a no-op: pixi skips artifacts already in the channel. + print(f"\n{len(RELEASE_SET)} packages -> {channel} (first-party at {version})") + if is_local(channel): + print("Next: pixi run release-verify (resolve a robot env against it)") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/release/deploy/mote-update b/release/deploy/mote-update new file mode 100755 index 0000000..e05573f --- /dev/null +++ b/release/deploy/mote-update @@ -0,0 +1,292 @@ +#!/usr/bin/env bash +# Install, cut over to, and roll back released Mote versions on a robot. +# +# mote-update status +# mote-update stage # install a slot; the running robot is untouched +# mote-update cutover # stop services, flip `current`, start, health-gate +# mote-update update # stage + cutover +# mote-update rollback # flip back to the previous slot +# mote-update prune # delete all but current + previous +# +# Deliberately plain bash with no dependency on a Mote environment: it has to +# run before the first slot exists, and it has to keep working when the slot it +# is cutting over to is broken. +# +# Layout under $MOTE_DEPLOY_ROOT (default ~/mote-deploy): +# +# versions// a slot: its own pixi.toml + pixi.lock + .pixi env +# current -> versions/X what systemd runs; flipped atomically +# previous a file naming the version to roll back to +# +# Two slots on disk, not N: conda hard-links identical packages between +# environments, so a point release costs disk only for what actually changed. +# +# $MOTE_HOME (default ~/.mote) -- identity, sites/maps, calibration, bags -- is +# outside the deploy root entirely and is never read or written here. That is +# what makes an update safe to repeat and a rollback safe to take. +set -euo pipefail + +DEPLOY_ROOT="${MOTE_DEPLOY_ROOT:-$HOME/mote-deploy}" +VERSIONS_DIR="$DEPLOY_ROOT/versions" +CURRENT_LINK="$DEPLOY_ROOT/current" +PREVIOUS_FILE="$DEPLOY_ROOT/previous" +PIXI="${PIXI:-$HOME/.pixi/bin/pixi}" +MANIFEST_REPO="${MOTE_MANIFEST_REPO:-https://raw.githubusercontent.com/ClachDev/Mote}" +HEALTH_TIMEOUT="${MOTE_HEALTH_TIMEOUT:-90}" + +# The units a cutover has to take down and bring back. mote-slam/nav/record are +# absent on purpose: they are BindsTo/PartOf mote-bringup, so systemd stops and +# starts them with it. +UNITS=(mote-bringup mote-health mote-agent) + +die() { echo "mote-update: $*" >&2; exit 1; } +log() { echo "==> $*"; } + +slot_dir() { echo "$VERSIONS_DIR/$1"; } + +current_version() { + [ -L "$CURRENT_LINK" ] || return 0 + basename "$(readlink -f "$CURRENT_LINK")" +} + +previous_version() { + [ -f "$PREVIOUS_FILE" ] && cat "$PREVIOUS_FILE" || true +} + +# Which of our units systemd is currently running. Only these are restarted, so +# a robot whose units are installed-but-not-enabled (the default -- see +# mote_bringup/systemd/install.sh) is not silently switched to autostart. +active_units() { + local unit + for unit in "${UNITS[@]}"; do + if systemctl is-active --quiet "$unit" 2>/dev/null; then echo "$unit"; fi + done +} + +fetch_manifest() { + # Resolution order: an explicit path, the environment, then the tag on + # GitHub. The last is what makes this headless and checkout-free -- the + # fleet orchestrator needs no more than a version string. + local version="$1" dest="$2" + if [ -n "${MANIFEST_PATH:-}" ]; then + log "manifest: $MANIFEST_PATH" + cp "$MANIFEST_PATH" "$dest" + elif [ -n "${MOTE_DEPLOY_MANIFEST:-}" ]; then + log "manifest: $MOTE_DEPLOY_MANIFEST" + cp "$MOTE_DEPLOY_MANIFEST" "$dest" + else + local url="$MANIFEST_REPO/v$version/release/deploy/pixi.toml" + log "manifest: $url" + curl -fsSL "$url" -o "$dest" \ + || die "could not fetch the deploy manifest for v$version" + fi + # The manifest ships with $HOME-relative placeholders so it stays + # machine-independent in the repo; pin them to this robot's real paths + # rather than relying on when (or whether) pixi expands them. + sed -i "s|\$HOME/mote-deploy/current|$CURRENT_LINK|g" "$dest" +} + +cmd_status() { + local current previous slot found unit + current="$(current_version)" + previous="$(previous_version)" + echo "deploy root: $DEPLOY_ROOT" + echo "current: ${current:-}" + echo "previous: ${previous:-}" + + echo "installed:" + found=0 + for slot in "$VERSIONS_DIR"/*; do + [ -d "$slot" ] || continue + echo " $(basename "$slot")" + found=1 + done + [ "$found" = "1" ] || echo " " + + echo "active units:" + found=0 + for unit in $(active_units); do + echo " $unit" + found=1 + done + [ "$found" = "1" ] || echo " " +} + +cmd_stage() { + local version="$1" + [ -n "$version" ] || die "usage: mote-update stage " + local slot + slot="$(slot_dir "$version")" + + if [ -d "$slot/.pixi" ]; then + log "$version already staged at $slot" + return 0 + fi + + # Stage into a scratch directory and move it into place only once the + # install has succeeded, so an interrupted or failed download never leaves a + # half-built slot that a later cutover would treat as ready. Same + # complete-then-flip rule the map registry uses for map revisions. + local staging="$VERSIONS_DIR/.staging-$version.$$" + rm -rf "$staging" + mkdir -p "$staging" + trap 'rm -rf "$staging"' EXIT + + fetch_manifest "$version" "$staging/pixi.toml" + + log "installing $version (the running robot is untouched)" + "$PIXI" install --manifest-path "$staging/pixi.toml" \ + || die "pixi install failed -- $version is NOT staged" + + mkdir -p "$VERSIONS_DIR" + rm -rf "$slot" + mv "$staging" "$slot" + trap - EXIT + log "staged $version at $slot" +} + +# Flip the `current` symlink in one rename, so there is no instant at which it +# is missing or pointing at nothing. +flip_to() { + local version="$1" + ln -sfn "$(slot_dir "$version")" "$CURRENT_LINK.tmp" + mv -T "$CURRENT_LINK.tmp" "$CURRENT_LINK" +} + +start_and_gate() { + local units="$1" version="$2" + [ -n "$units" ] || { log "no units were running; nothing to start"; return 0; } + + log "starting: $units" + # shellcheck disable=SC2086 + sudo systemctl start $units || true + + log "waiting up to ${HEALTH_TIMEOUT}s for units to settle" + local deadline=$((SECONDS + HEALTH_TIMEOUT)) unit failed + while [ $SECONDS -lt $deadline ]; do + failed="" + for unit in $units; do + systemctl is-active --quiet "$unit" || failed="$failed $unit" + done + [ -z "$failed" ] && { log "$version healthy: $units active"; return 0; } + sleep 3 + done + echo "mote-update: NOT healthy after ${HEALTH_TIMEOUT}s:$failed" >&2 + return 1 +} + +cmd_cutover() { + local version="$1" + [ -n "$version" ] || die "usage: mote-update cutover " + [ -d "$(slot_dir "$version")/.pixi" ] || die "$version is not staged" + + local from units + from="$(current_version)" + [ "$from" = "$version" ] && { log "already on $version"; return 0; } + units="$(active_units | tr '\n' ' ')" + + # The Pi has neither the CPU for two ROS stacks nor a second set of serial + # ports, so this is stop-then-start, not a hot swap. Hence: cut over when + # the robot is idle, never mid-mission. + if [ -n "$units" ]; then + log "stopping: $units" + # shellcheck disable=SC2086 + sudo systemctl stop $units + fi + + log "flipping current -> $version" + [ -n "$from" ] && echo "$from" > "$PREVIOUS_FILE" + flip_to "$version" + + # Reinstall the units from the new slot: their content can change between + # releases. MOTE_REPO points them at the `current` symlink, not at this + # slot, so a later cutover redirects them without reinstalling. + log "refreshing systemd units from $version" + MOTE_REPO="$CURRENT_LINK" "$PIXI" run --manifest-path "$CURRENT_LINK/pixi.toml" \ + install-systemd || log "WARNING: unit refresh failed; units still point at $CURRENT_LINK" + + if start_and_gate "$units" "$version"; then + log "cutover to $version complete" + return 0 + fi + + echo "mote-update: rolling back to ${from:-}" >&2 + [ -n "$from" ] || die "no previous version to roll back to -- robot is DOWN on $version" + cmd_rollback + die "cutover to $version failed; rolled back to $from" +} + +cmd_rollback() { + local target current units + target="$(previous_version)" + [ -n "$target" ] || die "no previous version recorded" + [ -d "$(slot_dir "$target")/.pixi" ] || die "previous version $target is not installed" + current="$(current_version)" + units="$(active_units | tr '\n' ' ')" + + if [ -n "$units" ]; then + log "stopping: $units" + # shellcheck disable=SC2086 + sudo systemctl stop $units + fi + + log "rolling back $current -> $target" + flip_to "$target" + [ -n "$current" ] && echo "$current" > "$PREVIOUS_FILE" + MOTE_REPO="$CURRENT_LINK" "$PIXI" run --manifest-path "$CURRENT_LINK/pixi.toml" \ + install-systemd || log "WARNING: unit refresh failed" + # No health gate here: rollback is the recovery path, and failing it would + # leave nowhere to go. Report and let the operator look. + if [ -n "$units" ]; then + log "starting: $units" + # shellcheck disable=SC2086 + sudo systemctl start $units || true + fi + log "rolled back to $target" +} + +cmd_update() { + cmd_stage "$1" + cmd_cutover "$1" +} + +cmd_prune() { + local keep_current keep_previous slot name + keep_current="$(current_version)" + keep_previous="$(previous_version)" + [ -d "$VERSIONS_DIR" ] || return 0 + for slot in "$VERSIONS_DIR"/*; do + [ -d "$slot" ] || continue + name="$(basename "$slot")" + if [ "$name" != "$keep_current" ] && [ "$name" != "$keep_previous" ]; then + log "removing $name" + rm -rf "$slot" + fi + done +} + +main() { + local command="${1:-status}" + shift || true + # --manifest is accepted before or after the version for convenience. + local args=() + while [ $# -gt 0 ]; do + case "$1" in + --manifest) MANIFEST_PATH="$2"; shift 2 ;; + --health-timeout) HEALTH_TIMEOUT="$2"; shift 2 ;; + *) args+=("$1"); shift ;; + esac + done + + case "$command" in + status) cmd_status ;; + stage) cmd_stage "${args[0]:-}" ;; + cutover) cmd_cutover "${args[0]:-}" ;; + update) cmd_update "${args[0]:-}" ;; + rollback) cmd_rollback ;; + prune) cmd_prune ;; + *) die "unknown command '$command' (status|stage|cutover|update|rollback|prune)" ;; + esac +} + +main "$@" diff --git a/release/deploy/pixi.toml b/release/deploy/pixi.toml new file mode 100644 index 0000000..fb20ff9 --- /dev/null +++ b/release/deploy/pixi.toml @@ -0,0 +1,140 @@ +# Mote robot deploy manifest -- GENERATED, do not edit by hand. +# +# Rendered by release/deploy_manifest.py from this template plus the workspace +# pixi.toml, and installed into a deploy slot by mote-update. What a robot runs +# is *this* environment: released conda packages from the `mote` channel, no +# source checkout, no colcon. `pixi run sync` remains the development loop +# (docs/releasing.md). +# +# The dependency lists below are copied from the workspace manifest at release +# time rather than maintained here, because a robot that resolves a different +# set of ROS packages than the one the release was tested against is not running +# the release. +[workspace] +name = "mote-robot" +channels = ["https://prefix.dev/mote", "robostack-jazzy", "conda-forge"] +platforms = ["linux-64", "linux-aarch64"] +version = "0.1.0" + +# The released first-party packages, pinned to the exact release version. They +# move as a set, so they pin as a set: a robot is running Mote 0.1.0, not a +# combination of package versions. +[dependencies] +ros-jazzy-mote-description = "==0.1.0" +ros-jazzy-mote-hardware = "==0.1.0" +ros-jazzy-mote-nav = "==0.1.0" +ros-jazzy-mote-bringup = "==0.1.0" +ros-jazzy-mote-perception = "==0.1.0" +ros-jazzy-mote-tasks = "==0.1.0" +ros-jazzy-mote-arm = "==0.1.0" +ros-jazzy-mote-fleet = "==0.1.0" +ros-jazzy-sllidar-ros2 = "*" +ros-jazzy-kinematic-icp = "*" +# Everything below is the workspace's own [dependencies], carried across so the +# deployed environment resolves what the release was built and tested against. +filelock = ">=3.12.2" +libcap = ">=2.77,<3" +lttng-ust = ">=2.13.9,<3" +paho-mqtt = ">=2,<3" +ros-jazzy-image-transport-plugins = ">=3.0.0" +ros-jazzy-laser-filters = ">=2.0.0" +ros-jazzy-nav2-amcl = ">=1.3.0" +ros-jazzy-nav2-behaviors = ">=1.3.0" +ros-jazzy-nav2-bt-navigator = ">=1.3.0" +ros-jazzy-nav2-controller = ">=1.3.0" +ros-jazzy-nav2-costmap-2d = ">=1.3.0" +ros-jazzy-nav2-dwb-controller = ">=1.3.0" +ros-jazzy-nav2-lifecycle-manager = ">=1.3.0" +ros-jazzy-nav2-map-server = ">=1.3.0" +ros-jazzy-nav2-mppi-controller = ">=1.3.0" +ros-jazzy-nav2-navfn-planner = ">=1.3.0" +ros-jazzy-nav2-planner = ">=1.3.0" +ros-jazzy-nav2-regulated-pure-pursuit-controller = ">=1.3.0" +ros-jazzy-nav2-rotation-shim-controller = ">=1.3.0" +ros-jazzy-nav2-smac-planner = ">=1.3.0" +ros-jazzy-nav2-smoother = ">=1.3.0" +ros-jazzy-nav2-waypoint-follower = ">=1.3.0" +ros-jazzy-rmw-cyclonedds-cpp = ">=1.3.0" +ros-jazzy-robot-state-publisher = ">=3.0.0" +ros-jazzy-ros2-control = ">=4.43.0,<5" +ros-jazzy-ros2-controllers = ">=4.37.0,<5" +ros-jazzy-ros2bag = ">=0.26.0" +ros-jazzy-ros2launch = ">=0.11.0" +ros-jazzy-ros2lifecycle = ">=0.18.0" +ros-jazzy-ros2run = ">=0.18.0" +ros-jazzy-rosbag2-cpp = ">=0.26.0" +ros-jazzy-rosbag2-storage = ">=0.26.0" +ros-jazzy-rosbag2-storage-mcap = ">=0.26.0" +ros-jazzy-rosbag2-transport = ">=0.26.0" +ros-jazzy-slam-toolbox = ">=2.8.4,<3" +ros-jazzy-teleop-twist-keyboard = ">=1.0.0" +ros-jazzy-v4l2-camera = ">=0.7.1,<0.8" +ros-jazzy-vision-msgs = ">=4.1.0" +ros-jazzy-xacro = ">=2.0.0" +scservo-linux = "==1.0.0" + +[pypi-dependencies] +# Not packaged on robostack/conda-forge, so they cannot come from the channel +# and are resolved from PyPI on the robot instead (docs/releasing.md). +feetech-servo-sdk = ">=1.0" +py-trees = ">=2.2,<3" + +[activation] +env = { RMW_IMPLEMENTATION = "rmw_cyclonedds_cpp", MOTE_REPO = "$HOME/mote-deploy/current" } + +# The operator surface of a deployed robot. Deliberately curated rather than +# copied from the workspace manifest: the build/test/sync tasks are meaningless +# without a checkout, and the sim and fleet-server tasks belong to other roles. +# Task *names* match the workspace's, so a runbook, a systemd unit or an +# operator's muscle memory works the same on a checkout and on a deploy. +[tasks] +# Missions +launch = "ros2 launch mote_bringup mote_launch.py" +mapping = "ros2 launch mote_bringup mapping_launch.py" +nav = "ros2 launch mote_bringup nav2_launch.py" +robot = "ros2 launch mote_bringup robot_launch.py" +slam = "ros2 launch mote_bringup slam_launch.py" +perception = "ros2 launch mote_perception perception_launch.py" +record = "ros2 launch mote_bringup record_launch.py" +tasks = "ros2 launch mote_tasks tasks_launch.py" +teleop = "ros2 run teleop_twist_keyboard teleop_twist_keyboard --ros-args -p stamped:=true -r /cmd_vel:=/diff_drive_controller/cmd_vel" + +# Health and state. These read and write $MOTE_HOME (default ~/.mote), which is +# outside the deploy slot and therefore survives every update and rollback. +health = "ros2 run mote_bringup health_monitor" +self-check = "ros2 run mote_bringup self_check" +site = "ros2 run mote_bringup site" +save-map = "ros2 run mote_bringup site save-map" +save-zone = "ros2 run mote_tasks save_zone" +clean-map = "python -m mote_bringup.map_cleanup.cli" +dds-check = "ros2 run mote_bringup dds_participants" +kill = "pkill -9 -f 'robot_state_publisher|ros2_control_node|sllidar|v4l2_camera|spawner'; ros2 daemon stop; rm -f /dev/shm/fast*; ros2 daemon start; true" + +# Fleet +identity = "ros2 run mote_bringup identity" +enroll = "ros2 run mote_fleet enroll" +agent = "ros2 launch mote_fleet agent_launch.py" + +# SO-101 arm +arm = "ros2 launch mote_arm arm_launch.py" +arm-jog = "ros2 run mote_arm jog" +arm-check = "ros2 run mote_arm arm_check" +arm-pose = "ros2 run mote_arm arm_pose" +arm-gains = "ros2 run mote_arm arm_gains" +setup-ids = "ros2 run mote_hardware setup_ids" + +# Robot-side inference diagnostics against the off-board GPU machine. +inference-health = "python -u $CONDA_PREFIX/share/mote_perception/tools/inference_health.py" +inference-bench = "python -u $CONDA_PREFIX/share/mote_perception/tools/inference_bench.py" + +# One-time host setup. The assets live in the installed package rather than a +# checkout, which is why mote_bringup ships udev/, systemd/, networkmanager/ and +# tailscale/ into share (see its setup.py). +udev = "sudo cp $CONDA_PREFIX/share/mote_bringup/udev/99-mote.rules /etc/udev/rules.d/ && sudo udevadm control --reload-rules && sudo udevadm trigger && sudo usermod -aG dialout $USER" +wifi-powersave = "bash $CONDA_PREFIX/share/mote_bringup/networkmanager/install.sh" +# MOTE_REPO comes from [activation] above and points at the `current` symlink, +# so the units it writes follow a cutover instead of pinning to this slot. +install-systemd = "bash $CONDA_PREFIX/share/mote_bringup/systemd/install.sh" +setup = { depends-on = ["udev", "wifi-powersave", "install-systemd"] } +tailnet = "bash $CONDA_PREFIX/share/mote_bringup/tailscale/install.sh" +provision = "ros2 run mote_bringup provision" diff --git a/release/deploy/pixi.toml.in b/release/deploy/pixi.toml.in new file mode 100644 index 0000000..e4dca04 --- /dev/null +++ b/release/deploy/pixi.toml.in @@ -0,0 +1,91 @@ +# Mote robot deploy manifest -- GENERATED, do not edit by hand. +# +# Rendered by release/deploy_manifest.py from this template plus the workspace +# pixi.toml, and installed into a deploy slot by mote-update. What a robot runs +# is *this* environment: released conda packages from the `mote` channel, no +# source checkout, no colcon. `pixi run sync` remains the development loop +# (docs/releasing.md). +# +# The dependency lists below are copied from the workspace manifest at release +# time rather than maintained here, because a robot that resolves a different +# set of ROS packages than the one the release was tested against is not running +# the release. +[workspace] +name = "mote-robot" +channels = @CHANNELS@ +platforms = @PLATFORMS@ +version = "@VERSION@" + +# The released first-party packages, pinned to the exact release version. They +# move as a set, so they pin as a set: a robot is running Mote @VERSION@, not a +# combination of package versions. +[dependencies] +@MOTE_PACKAGES@ +# Everything below is the workspace's own [dependencies], carried across so the +# deployed environment resolves what the release was built and tested against. +@DEPENDENCIES@ + +[pypi-dependencies] +# Not packaged on robostack/conda-forge, so they cannot come from the channel +# and are resolved from PyPI on the robot instead (docs/releasing.md). +@PYPI_DEPENDENCIES@ + +[activation] +env = { RMW_IMPLEMENTATION = "rmw_cyclonedds_cpp", MOTE_REPO = "@DEPLOY_CURRENT@" } + +# The operator surface of a deployed robot. Deliberately curated rather than +# copied from the workspace manifest: the build/test/sync tasks are meaningless +# without a checkout, and the sim and fleet-server tasks belong to other roles. +# Task *names* match the workspace's, so a runbook, a systemd unit or an +# operator's muscle memory works the same on a checkout and on a deploy. +[tasks] +# Missions +launch = "ros2 launch mote_bringup mote_launch.py" +mapping = "ros2 launch mote_bringup mapping_launch.py" +nav = "ros2 launch mote_bringup nav2_launch.py" +robot = "ros2 launch mote_bringup robot_launch.py" +slam = "ros2 launch mote_bringup slam_launch.py" +perception = "ros2 launch mote_perception perception_launch.py" +record = "ros2 launch mote_bringup record_launch.py" +tasks = "ros2 launch mote_tasks tasks_launch.py" +teleop = "ros2 run teleop_twist_keyboard teleop_twist_keyboard --ros-args -p stamped:=true -r /cmd_vel:=/diff_drive_controller/cmd_vel" + +# Health and state. These read and write $MOTE_HOME (default ~/.mote), which is +# outside the deploy slot and therefore survives every update and rollback. +health = "ros2 run mote_bringup health_monitor" +self-check = "ros2 run mote_bringup self_check" +site = "ros2 run mote_bringup site" +save-map = "ros2 run mote_bringup site save-map" +save-zone = "ros2 run mote_tasks save_zone" +clean-map = "python -m mote_bringup.map_cleanup.cli" +dds-check = "ros2 run mote_bringup dds_participants" +kill = "pkill -9 -f 'robot_state_publisher|ros2_control_node|sllidar|v4l2_camera|spawner'; ros2 daemon stop; rm -f /dev/shm/fast*; ros2 daemon start; true" + +# Fleet +identity = "ros2 run mote_bringup identity" +enroll = "ros2 run mote_fleet enroll" +agent = "ros2 launch mote_fleet agent_launch.py" + +# SO-101 arm +arm = "ros2 launch mote_arm arm_launch.py" +arm-jog = "ros2 run mote_arm jog" +arm-check = "ros2 run mote_arm arm_check" +arm-pose = "ros2 run mote_arm arm_pose" +arm-gains = "ros2 run mote_arm arm_gains" +setup-ids = "ros2 run mote_hardware setup_ids" + +# Robot-side inference diagnostics against the off-board GPU machine. +inference-health = "python -u $CONDA_PREFIX/share/mote_perception/tools/inference_health.py" +inference-bench = "python -u $CONDA_PREFIX/share/mote_perception/tools/inference_bench.py" + +# One-time host setup. The assets live in the installed package rather than a +# checkout, which is why mote_bringup ships udev/, systemd/, networkmanager/ and +# tailscale/ into share (see its setup.py). +udev = "sudo cp $CONDA_PREFIX/share/mote_bringup/udev/99-mote.rules /etc/udev/rules.d/ && sudo udevadm control --reload-rules && sudo udevadm trigger && sudo usermod -aG dialout $USER" +wifi-powersave = "bash $CONDA_PREFIX/share/mote_bringup/networkmanager/install.sh" +# MOTE_REPO comes from [activation] above and points at the `current` symlink, +# so the units it writes follow a cutover instead of pinning to this slot. +install-systemd = "bash $CONDA_PREFIX/share/mote_bringup/systemd/install.sh" +setup = { depends-on = ["udev", "wifi-powersave", "install-systemd"] } +tailnet = "bash $CONDA_PREFIX/share/mote_bringup/tailscale/install.sh" +provision = "ros2 run mote_bringup provision" diff --git a/release/deploy_manifest.py b/release/deploy_manifest.py new file mode 100755 index 0000000..1ddcd33 --- /dev/null +++ b/release/deploy_manifest.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +"""Render the robot deploy manifest for a release. + +The deploy manifest is the pixi.toml a robot actually runs: released conda +packages from the `mote` channel and nothing else -- no checkout, no colcon. + +Its *dependencies* are generated rather than hand-maintained. The workspace +manifest already lists the ROS packages the robot needs, and a deployed robot +that resolved a different set than the release was tested against would be a +different robot. Its *tasks* are hand-written in the template, because the +deployed task surface is a curated subset (no build, no sync, no sim). + +The rendered file is committed to the repo under release/deploy/pixi.toml, which +is how a robot gets it without a checkout: mote-update fetches it by tag. + +Usage: + release/deploy_manifest.py # -> release/deploy/pixi.toml + release/deploy_manifest.py --output - # to stdout + release/deploy_manifest.py --channel file:///path/to/dist/channel +""" + +from __future__ import annotations + +import argparse +import sys +import tomllib +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from build import RELEASE_SET # noqa: E402 +from version import REPO_ROOT, current_version # noqa: E402 + +TEMPLATE = Path(__file__).resolve().parent / "deploy" / "pixi.toml.in" +DEFAULT_OUTPUT = Path(__file__).resolve().parent / "deploy" / "pixi.toml" + +# Where a robot's deploy slots live, and the stable symlink the systemd units +# point at. Kept in step with mote-update. +DEPLOY_CURRENT = "$HOME/mote-deploy/current" + +# Workspace dependencies that must not be carried into a robot deploy: the build +# toolchain, because a deployed robot compiles nothing. Everything else is +# carried across, including runtime libraries a released package already depends +# on (scservo-linux) -- the duplication is harmless and pins the version the +# release was actually tested against. +SKIP_DEPENDENCIES = { + "cmake", + "compilers", + "ninja", + "pkg-config", + "colcon-common-extensions", + "ros-jazzy-ament-cmake-auto", + "ros-jazzy-ament-lint-auto", + "ros-jazzy-ament-cmake-gtest", +} + + +def toml_value(value: object) -> str: + """Render a dependency spec back to TOML (a string pin, or a table).""" + if isinstance(value, str): + return f'"{value}"' + if isinstance(value, dict): + inner = ", ".join(f"{k} = {toml_value(v)}" for k, v in value.items()) + return f"{{ {inner} }}" + raise TypeError(f"unsupported dependency spec: {value!r}") + + +def render_dependencies(dependencies: dict[str, object]) -> str: + return "\n".join( + f"{name} = {toml_value(spec)}" + for name, spec in sorted(dependencies.items()) + if name not in SKIP_DEPENDENCIES + ) + + +def render_mote_packages(version: str) -> str: + """Pin every released first-party package to the exact release version. + + `==` and not a range: the packages are co-developed and released together, + so a robot mixing versions is a configuration nobody has tested. The two + submodule packages are pinned loosely by comparison -- they carry upstream + versions that this repo does not bump. + """ + lines = [] + for name, manifest in RELEASE_SET: + if manifest.startswith("release/third_party/"): + lines.append(f'{name} = "*"') + else: + lines.append(f'{name} = "=={version}"') + return "\n".join(lines) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + parser.add_argument( + "--output", + default=str(DEFAULT_OUTPUT), + help="where to write the manifest ('-' for stdout)", + ) + parser.add_argument( + "--channel", + default=None, + help=( + "override the mote channel -- point it at a local dist/channel to " + "resolve a release that has not been published yet" + ), + ) + parser.add_argument( + "--platforms", + default=None, + help=( + "comma-separated platforms (default: linux-64,linux-aarch64). " + "release/verify.py narrows this to the host, because a local " + "dist/channel only holds the architecture that was just built" + ), + ) + args = parser.parse_args() + + workspace = tomllib.loads((REPO_ROOT / "pixi.toml").read_text()) + version = current_version() + + channels = list(workspace["workspace"]["channels"]) + if args.channel: + # Prepend rather than replace. During a dry-run the freshly built + # packages must win, but the `mote` channel has to stay reachable: the + # release depends on things already published there that this build does + # not produce (scservo-linux, which ros-jazzy-mote-hardware links). + channels = [args.channel] + channels + + platforms = ( + args.platforms.split(",") if args.platforms else ["linux-64", "linux-aarch64"] + ) + + rendered = ( + TEMPLATE.read_text() + .replace("@VERSION@", version) + .replace("@PLATFORMS@", "[" + ", ".join(f'"{p}"' for p in platforms) + "]") + .replace("@CHANNELS@", "[" + ", ".join(f'"{c}"' for c in channels) + "]") + .replace("@MOTE_PACKAGES@", render_mote_packages(version)) + .replace("@DEPENDENCIES@", render_dependencies(workspace["dependencies"])) + .replace( + "@PYPI_DEPENDENCIES@", render_dependencies(workspace["pypi-dependencies"]) + ) + .replace("@DEPLOY_CURRENT@", DEPLOY_CURRENT) + ) + + if args.output == "-": + print(rendered, end="") + else: + output = Path(args.output) + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(rendered) + # release/verify.py renders into a temp directory, so the path is not + # always under the repo. + shown = ( + output.relative_to(REPO_ROOT) + if output.is_relative_to(REPO_ROOT) + else output + ) + print(f"wrote {shown} for version {version}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/release/evidence/dry-run-linux-64.json b/release/evidence/dry-run-linux-64.json new file mode 100644 index 0000000..f872fc0 --- /dev/null +++ b/release/evidence/dry-run-linux-64.json @@ -0,0 +1,60 @@ +{ + "version": "0.1.0", + "platform": "linux-64", + "manifest": "release/deploy/pixi.toml (channel overridden to dist/channel)", + "channel": "dist/channel (local, indexed)", + "packages_built": [ + "ros-jazzy-mote-description", + "ros-jazzy-mote-hardware", + "ros-jazzy-mote-nav", + "ros-jazzy-mote-bringup", + "ros-jazzy-mote-perception", + "ros-jazzy-mote-tasks", + "ros-jazzy-mote-arm", + "ros-jazzy-mote-fleet", + "ros-jazzy-sllidar-ros2", + "ros-jazzy-kinematic-icp" + ], + "resolved": { + "ros-jazzy-kinematic-icp": { + "version": "0.1.1", + "url": "dist/channel/linux-64/ros-jazzy-kinematic-icp-0.1.1-hb0f4dca_0.conda" + }, + "ros-jazzy-mote-arm": { + "version": "0.1.0", + "url": "dist/channel/linux-64/ros-jazzy-mote-arm-0.1.0-hb0f4dca_0.conda" + }, + "ros-jazzy-mote-bringup": { + "version": "0.1.0", + "url": "dist/channel/linux-64/ros-jazzy-mote-bringup-0.1.0-hb0f4dca_0.conda" + }, + "ros-jazzy-mote-description": { + "version": "0.1.0", + "url": "dist/channel/linux-64/ros-jazzy-mote-description-0.1.0-hb0f4dca_0.conda" + }, + "ros-jazzy-mote-fleet": { + "version": "0.1.0", + "url": "dist/channel/linux-64/ros-jazzy-mote-fleet-0.1.0-hb0f4dca_0.conda" + }, + "ros-jazzy-mote-hardware": { + "version": "0.1.0", + "url": "dist/channel/linux-64/ros-jazzy-mote-hardware-0.1.0-hb0f4dca_0.conda" + }, + "ros-jazzy-mote-nav": { + "version": "0.1.0", + "url": "dist/channel/linux-64/ros-jazzy-mote-nav-0.1.0-hb0f4dca_0.conda" + }, + "ros-jazzy-mote-perception": { + "version": "0.1.0", + "url": "dist/channel/linux-64/ros-jazzy-mote-perception-0.1.0-hb0f4dca_0.conda" + }, + "ros-jazzy-mote-tasks": { + "version": "0.1.0", + "url": "dist/channel/linux-64/ros-jazzy-mote-tasks-0.1.0-hb0f4dca_0.conda" + }, + "ros-jazzy-sllidar-ros2": { + "version": "1.0.1", + "url": "dist/channel/linux-64/ros-jazzy-sllidar-ros2-1.0.1-hb0f4dca_0.conda" + } + } +} diff --git a/release/test/test_mote_update.py b/release/test/test_mote_update.py new file mode 100644 index 0000000..6c6ade9 --- /dev/null +++ b/release/test/test_mote_update.py @@ -0,0 +1,202 @@ +"""Rehearse the robot update flow: stage, cut over, roll back. + +The expensive half of an update is `pixi install`, and it is also the half that +is not interesting to test -- pixi's job, not ours. What *is* ours is the state +machine around it: slots that only become visible once complete, a `current` +symlink that flips atomically, a recorded previous version, a rollback that +returns to it, and the promise that none of it touches the robot's own state. + +So these tests run the real mote-update script against a stub pixi, which makes +a full stage/cutover/rollback cycle a fraction of a second instead of several +gigabytes. The on-robot rehearsal (docs/releasing.md) covers the parts a stub +cannot: the real install, systemd, and the hardware coming back up. +""" + +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + +import pytest + +RELEASE_DIR = Path(__file__).resolve().parent.parent +MOTE_UPDATE = RELEASE_DIR / "deploy" / "mote-update" +DEPLOY_MANIFEST = RELEASE_DIR / "deploy" / "pixi.toml" + +# A stand-in for pixi that does what mote-update depends on and nothing else: +# `install` materialises the .pixi directory that marks a slot as ready, and +# `run install-systemd` records the MOTE_REPO it was handed so a test can assert +# the units would be pointed at the `current` symlink rather than a slot. +STUB_PIXI = """#!/usr/bin/env bash +set -euo pipefail +command="$1"; shift +manifest="" +while [ $# -gt 0 ]; do + case "$1" in + --manifest-path) manifest="$2"; shift 2 ;; + *) shift ;; + esac +done +case "$command" in + install) mkdir -p "$(dirname "$manifest")/.pixi/envs/default" ;; + run) echo "$MOTE_REPO" >> "$STUB_LOG" ;; +esac +""" + + +@pytest.fixture +def robot(tmp_path): + """A fake robot: a deploy root, a stub pixi, and some precious state.""" + pixi = tmp_path / "bin" / "pixi" + pixi.parent.mkdir() + pixi.write_text(STUB_PIXI) + pixi.chmod(0o755) + + # $MOTE_HOME -- identity, maps, calibration. Deliberately a sibling of the + # deploy root, which is the whole point: updates must not be able to reach it. + mote_home = tmp_path / ".mote" + (mote_home / "sites").mkdir(parents=True) + (mote_home / "robot.yaml").write_text("robot_id: mote-01\n") + (mote_home / "sites" / "map.png").write_bytes(b"\x89PNG-not-really") + + env = { + **os.environ, + "MOTE_DEPLOY_ROOT": str(tmp_path / "mote-deploy"), + "MOTE_HOME": str(mote_home), + "PIXI": str(pixi), + "STUB_LOG": str(tmp_path / "stub.log"), + } + return {"env": env, "root": tmp_path, "mote_home": mote_home} + + +def run(robot, *args, check=True): + return subprocess.run( + [str(MOTE_UPDATE), *args, "--manifest", str(DEPLOY_MANIFEST)], + env=robot["env"], + capture_output=True, + text=True, + check=check, + ) + + +def deploy_root(robot) -> Path: + return Path(robot["env"]["MOTE_DEPLOY_ROOT"]) + + +def state_fingerprint(mote_home: Path) -> dict[str, bytes]: + return { + str(p.relative_to(mote_home)): p.read_bytes() + for p in sorted(mote_home.rglob("*")) + if p.is_file() + } + + +def test_stage_does_not_disturb_the_running_version(robot): + run(robot, "stage", "0.1.0") + run(robot, "cutover", "0.1.0") + assert (deploy_root(robot) / "current").resolve().name == "0.1.0" + + # Staging a second version must leave `current` exactly where it was: the + # robot keeps running the old stack while the new one downloads. + run(robot, "stage", "0.2.0") + assert (deploy_root(robot) / "current").resolve().name == "0.1.0" + assert (deploy_root(robot) / "versions" / "0.2.0" / ".pixi").is_dir() + + +def test_cutover_records_the_previous_version(robot): + run(robot, "stage", "0.1.0") + run(robot, "cutover", "0.1.0") + run(robot, "stage", "0.2.0") + run(robot, "cutover", "0.2.0") + + assert (deploy_root(robot) / "current").resolve().name == "0.2.0" + assert (deploy_root(robot) / "previous").read_text().strip() == "0.1.0" + + +def test_rollback_returns_to_the_previous_version(robot): + run(robot, "stage", "0.1.0") + run(robot, "cutover", "0.1.0") + run(robot, "stage", "0.2.0") + run(robot, "cutover", "0.2.0") + + run(robot, "rollback") + assert (deploy_root(robot) / "current").resolve().name == "0.1.0" + # And rolling back again returns to where we were -- previous is swapped, + # so an operator who rolls back by mistake is not stuck. + assert (deploy_root(robot) / "previous").read_text().strip() == "0.2.0" + run(robot, "rollback") + assert (deploy_root(robot) / "current").resolve().name == "0.2.0" + + +def test_rollback_without_a_previous_version_fails_loudly(robot): + run(robot, "stage", "0.1.0") + run(robot, "cutover", "0.1.0") + result = run(robot, "rollback", check=False) + assert result.returncode != 0 + assert "no previous version" in result.stderr + + +def test_cutover_refuses_an_unstaged_version(robot): + """The failure has to happen before anything is stopped, not after.""" + result = run(robot, "cutover", "9.9.9", check=False) + assert result.returncode != 0 + assert "not staged" in result.stderr + assert not (deploy_root(robot) / "current").exists() + + +def test_a_failed_stage_leaves_no_usable_slot(robot): + """A half-installed slot that a later cutover would accept is the dangerous case.""" + broken = robot["root"] / "bin" / "pixi" + broken.write_text("#!/usr/bin/env bash\nexit 1\n") + broken.chmod(0o755) + + result = run(robot, "stage", "0.3.0", check=False) + assert result.returncode != 0 + assert not (deploy_root(robot) / "versions" / "0.3.0").exists() + + +def test_units_are_pointed_at_the_current_symlink(robot): + """Not at the slot: otherwise every cutover would need the units reinstalled.""" + run(robot, "stage", "0.1.0") + run(robot, "cutover", "0.1.0") + seen = Path(robot["env"]["STUB_LOG"]).read_text().split() + assert seen, "install-systemd was never invoked" + assert all(s == str(deploy_root(robot) / "current") for s in seen) + + +def test_robot_state_survives_a_full_update_cycle(robot): + """~/.mote must come out of stage -> cutover -> rollback byte-identical. + + Identity, site maps and calibration are what make a robot *that* robot; an + update that could damage them would have to be treated as a risky operation + rather than a routine one. + """ + before = state_fingerprint(robot["mote_home"]) + assert before, "the fixture wrote no state to check" + + run(robot, "stage", "0.1.0") + run(robot, "cutover", "0.1.0") + run(robot, "stage", "0.2.0") + run(robot, "cutover", "0.2.0") + run(robot, "rollback") + run(robot, "prune") + + assert state_fingerprint(robot["mote_home"]) == before + + +def test_prune_keeps_current_and_previous(robot): + for version in ("0.1.0", "0.2.0", "0.3.0"): + run(robot, "stage", version) + run(robot, "cutover", version) + run(robot, "prune") + + remaining = sorted(p.name for p in (deploy_root(robot) / "versions").iterdir()) + assert remaining == ["0.2.0", "0.3.0"] + + +def test_status_reports_the_slots(robot): + run(robot, "stage", "0.1.0") + run(robot, "cutover", "0.1.0") + out = run(robot, "status").stdout + assert "current: 0.1.0" in out diff --git a/release/test/test_release_tooling.py b/release/test/test_release_tooling.py new file mode 100644 index 0000000..dead1fa --- /dev/null +++ b/release/test/test_release_tooling.py @@ -0,0 +1,227 @@ +"""Tests for the release tooling. + +These guard the things that are easy to get silently wrong and expensive to +discover late: a new package that nobody added to the release set, a build +manifest that stopped pinning the backend, a deploy manifest that drifted from +the workspace, and the promise that an update cannot touch a robot's state. + +They are deliberately cheap -- no builds, no network, no ROS -- so they can run +in CI next to the unit tests rather than only when someone cuts a release. +""" + +from __future__ import annotations + +import re +import shutil +import subprocess +import sys +import tomllib +from pathlib import Path + +import pytest + +RELEASE_DIR = Path(__file__).resolve().parent.parent +REPO_ROOT = RELEASE_DIR.parent +sys.path.insert(0, str(RELEASE_DIR)) + +from build import RELEASE_SET # noqa: E402 +from version import RELEASED_PACKAGES, version_files # noqa: E402 + + +def first_party() -> list[str]: + return [name for name, path in RELEASE_SET if not path.startswith("release/")] + + +# --- the release set ------------------------------------------------------ + + +def test_every_first_party_package_is_released_or_deliberately_excluded(): + """A new mote_* package must be added to the release set or excluded on purpose. + + Without this, adding a package and forgetting the release set produces a + robot deploy that is quietly missing it -- and the failure appears at + launch time on the robot, not at release time. + """ + on_disk = {path.parent.name for path in REPO_ROOT.glob("mote_*/package.xml")} + # mote_simulation is workstation-only: excluded by policy (docs/releasing.md). + expected = on_disk - {"mote_simulation"} + assert { + p.removeprefix("ros-jazzy-").replace("-", "_") for p in first_party() + } == expected + + +def test_release_set_matches_version_tool(): + """version.py and build.py must agree on which packages carry the release version.""" + assert sorted(RELEASED_PACKAGES) == sorted( + p.removeprefix("ros-jazzy-").replace("-", "_") for p in first_party() + ) + + +@pytest.mark.parametrize("name,manifest_path", RELEASE_SET) +def test_every_released_package_pins_the_build_backend(name, manifest_path): + """pixi-build is a preview feature, so an unpinned backend is a moving target. + + A release built with whatever backend happened to be newest is not + reproducible, and the backend has changed its config schema between minor + versions (docs/releasing.md). + """ + manifest = REPO_ROOT / manifest_path / "pixi.toml" + assert manifest.exists(), f"{name} has no build manifest at {manifest}" + build = tomllib.loads(manifest.read_text())["package"]["build"] + assert build["backend"]["name"] == "pixi-build-ros" + assert re.match(r"^\d+\.\d+\.\d+", build["backend"]["version"]), ( + f"{name} does not pin a backend version" + ) + assert build["config"]["distro"] == "jazzy" + + +def test_build_manifests_do_not_duplicate_the_version(): + """package.xml is the single source of truth the version tool bumps. + + A `version` in the build manifest would be a second place to bump and a + silent way to publish a package whose version disagrees with its manifest. + """ + for name, manifest_path in RELEASE_SET: + manifest = tomllib.loads((REPO_ROOT / manifest_path / "pixi.toml").read_text()) + assert "version" not in manifest.get("package", {}), ( + f"{name}'s build manifest pins a version; it must come from package.xml" + ) + + +# --- versions ------------------------------------------------------------- + + +def test_version_is_consistent(): + result = subprocess.run( + [sys.executable, str(RELEASE_DIR / "version.py"), "check"], + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + + +def test_version_set_round_trip(tmp_path): + """`set` must reach every file `check` looks at, or a release ships a mixed set.""" + work = tmp_path / "repo" + work.mkdir() + shutil.copy(REPO_ROOT / "pixi.toml", work / "pixi.toml") + for package in RELEASED_PACKAGES: + (work / package).mkdir() + for filename in ("package.xml", "setup.py"): + source = REPO_ROOT / package / filename + if source.exists(): + shutil.copy(source, work / package / filename) + + # version.py locates the repo root relative to itself, so the copy has to + # sit at the same depth it does in the real tree. + (work / "release").mkdir() + script = work / "release" / "version.py" + shutil.copy(RELEASE_DIR / "version.py", script) + subprocess.run([sys.executable, str(script), "set", "9.8.7"], check=True, cwd=work) + check = subprocess.run( + [sys.executable, str(script), "check"], capture_output=True, text=True, cwd=work + ) + assert check.returncode == 0, check.stderr + assert "9.8.7" in (work / "pixi.toml").read_text() + for file in version_files(): + copied = work / file.path.relative_to(REPO_ROOT) + assert "9.8.7" in copied.read_text(), f"{copied} was not bumped" + + +# --- the deploy manifest -------------------------------------------------- + + +@pytest.fixture(scope="module") +def deploy_manifest(tmp_path_factory) -> dict: + output = tmp_path_factory.mktemp("deploy") / "pixi.toml" + subprocess.run( + [ + sys.executable, + str(RELEASE_DIR / "deploy_manifest.py"), + "--output", + str(output), + ], + check=True, + cwd=REPO_ROOT, + ) + return tomllib.loads(output.read_text()) + + +def test_deploy_manifest_pins_every_first_party_package(deploy_manifest): + version = deploy_manifest["workspace"]["version"] + dependencies = deploy_manifest["dependencies"] + for name in first_party(): + assert dependencies[name] == f"=={version}", ( + f"{name} is not pinned to the release version" + ) + + +def test_deploy_manifest_includes_the_submodule_packages(deploy_manifest): + """A robot with no lidar driver and no odometry is not a deployed robot.""" + assert "ros-jazzy-sllidar-ros2" in deploy_manifest["dependencies"] + assert "ros-jazzy-kinematic-icp" in deploy_manifest["dependencies"] + + +def test_deploy_manifest_excludes_build_tooling(deploy_manifest): + """A deployed robot compiles nothing, so it should not carry a toolchain.""" + for tool in ("cmake", "compilers", "ninja", "colcon-common-extensions"): + assert tool not in deploy_manifest["dependencies"] + + +def test_deploy_task_names_exist_in_the_workspace(deploy_manifest): + """Same task names on a checkout and a deploy. + + A runbook, a systemd unit or an operator's habit should not have to know + which of the two it is talking to. + """ + workspace = tomllib.loads((REPO_ROOT / "pixi.toml").read_text()) + workspace_tasks = set(workspace["tasks"]) + for feature in workspace.get("feature", {}).values(): + workspace_tasks |= set(feature.get("tasks", {})) + unknown = set(deploy_manifest["tasks"]) - workspace_tasks + assert not unknown, ( + f"deploy tasks that no workspace task matches: {sorted(unknown)}" + ) + + +def test_systemd_units_are_runnable_from_a_deploy(deploy_manifest): + """Every `pixi run X` a unit invokes must exist in the deployed task set.""" + units = sorted((REPO_ROOT / "mote_bringup" / "systemd").glob("*.service")) + assert units, "no systemd units found" + invoked = set() + for unit in units: + for line in unit.read_text().splitlines(): + match = re.search(r"pixi run ([\w-]+)", line) + if match: + invoked.add(match.group(1)) + missing = invoked - set(deploy_manifest["tasks"]) + assert not missing, f"units invoke tasks a deploy does not have: {sorted(missing)}" + + +def test_committed_deploy_manifest_is_up_to_date(deploy_manifest): + """release/deploy/pixi.toml is what a robot fetches by tag, so it must not drift.""" + committed = tomllib.loads((RELEASE_DIR / "deploy" / "pixi.toml").read_text()) + assert committed == deploy_manifest, ( + "release/deploy/pixi.toml is stale -- run: pixi run release-manifest" + ) + + +# --- runtime state safety ------------------------------------------------- + + +def test_updates_cannot_touch_robot_state(): + """~/.mote must be outside everything an update writes. + + Identity, site maps, calibration and bags live in $MOTE_HOME. The update + mechanism keeps its slots under $MOTE_DEPLOY_ROOT and must never read or + write the other, which is what makes an update repeatable and a rollback + safe. + """ + script = (RELEASE_DIR / "deploy" / "mote-update").read_text() + body = "\n".join( + line for line in script.splitlines() if not line.lstrip().startswith("#") + ) + assert "MOTE_HOME" not in body, "mote-update refers to MOTE_HOME" + assert ".mote/" not in body and '"$HOME/.mote"' not in body + # And the two roots must be different directories. + assert "mote-deploy" in script diff --git a/release/third_party/kinematic_icp/pixi.toml b/release/third_party/kinematic_icp/pixi.toml new file mode 100644 index 0000000..52d17ff --- /dev/null +++ b/release/third_party/kinematic_icp/pixi.toml @@ -0,0 +1,21 @@ +# Out-of-tree build definition for the `kinematic_icp` submodule. +# +# The manifest lives here rather than in third_party/kinematic_icp/ros/ because +# that tree is a git submodule: a pixi.toml added inside it would be an +# untracked file in someone else's repo, lost on the next clone. `source.path` +# points the backend at the submodule instead. The version comes from its +# package.xml. +[package.build] +backend = { name = "pixi-build-ros", version = "0.5.0.*" } +source = { path = "../../../third_party/kinematic_icp/ros" } + +[package.build.config] +distro = "jazzy" +# kinematic_icp's core pulls kiss-icp/tessil through CMake FetchContent, and +# tessil still declares a pre-3.5 cmake_minimum_required. Same workaround the +# colcon build uses (`pixi run build`), passed through the generated script's +# $EXTRA_CMAKE_ARGS hook. +env = { EXTRA_CMAKE_ARGS = "-DCMAKE_POLICY_VERSION_MINIMUM=3.5" } +# Sophus is a conda-forge C++ header library, not a ROS package, so the +# backend's `ros-jazzy-` fallback would look for a package that does not exist. +extra-package-mappings = [{ sophus = { robostack = ["sophus"] } }] diff --git a/release/third_party/sllidar_ros2/pixi.toml b/release/third_party/sllidar_ros2/pixi.toml new file mode 100644 index 0000000..48f5171 --- /dev/null +++ b/release/third_party/sllidar_ros2/pixi.toml @@ -0,0 +1,12 @@ +# Out-of-tree build definition for the `sllidar_ros2` submodule. +# +# The manifest lives here rather than in third_party/sllidar_ros2/ because that +# tree is a git submodule: a pixi.toml added inside it would be an untracked +# file in someone else's repo, lost on the next clone. `source.path` points the +# backend at the submodule instead. The version comes from its package.xml. +[package.build] +backend = { name = "pixi-build-ros", version = "0.5.0.*" } +source = { path = "../../../third_party/sllidar_ros2" } + +[package.build.config] +distro = "jazzy" diff --git a/release/verify.py b/release/verify.py new file mode 100755 index 0000000..9bd8466 --- /dev/null +++ b/release/verify.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +"""Resolve a robot environment against the freshly built packages. + +This is the dry-run with teeth. `release/build.py` proves each package builds; +this proves the release is *installable as a set*: the robot's deploy manifest, +pinned to the new version, must solve against the local dist/channel plus +robostack/conda-forge. It is the check that catches a package that built but +declares a dependency nothing provides, a version pin that no longer resolves, +or a first-party package accidentally left out of the release set. + +It resolves (writes a lockfile) rather than installs -- solving is where the +answer is, and a full install would pull gigabytes of ROS to learn nothing more. + +Only the host platform is verified, because a local channel holds only the +architecture that was just built. The other architecture is verified by the same +command on a runner of that architecture (docs/releasing.md). + +Usage: + release/verify.py # against dist/channel + release/verify.py --channel dist/channel --evidence release/evidence +""" + +from __future__ import annotations + +import argparse +import json +import platform +import subprocess +import sys +import tempfile +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from build import RELEASE_SET # noqa: E402 +from version import REPO_ROOT, current_version # noqa: E402 + +HERE = Path(__file__).resolve().parent + + +def host_platform() -> str: + machine = platform.machine() + return "linux-aarch64" if machine in ("aarch64", "arm64") else "linux-64" + + +def resolved_mote_packages(lock_path: Path, subdir: str) -> dict[str, dict[str, str]]: + """Pull the mote package versions out of the lockfile. + + Read as text rather than parsed as YAML: pixi.lock is large, the field we + want is in the artifact URL, and this keeps the release tooling free of a + YAML dependency it would otherwise need only here. + """ + wanted = {name for name, _ in RELEASE_SET} + found: dict[str, dict[str, str]] = {} + for raw in lock_path.read_text().splitlines(): + line = raw.strip() + if not line.startswith("- conda:") or f"/{subdir}/" not in line: + continue + filename = line.rsplit("/", 1)[-1] + if not filename.endswith((".conda", ".tar.bz2")): + continue + stem = filename.removesuffix(".conda").removesuffix(".tar.bz2") + # Artifact names are --, and neither version nor + # build may contain a dash, so splitting from the right is exact. + parts = stem.rsplit("-", 2) + if len(parts) == 3 and parts[0] in wanted: + # Keep the URL, not just the version: it records which channel the + # solver actually took the package from, which is the part of the + # dry-run worth being able to check later. + url = line.removeprefix("- conda:").strip() + # Make a local channel hit repo-relative: the evidence is committed, + # and whose machine ran the build is not part of it. + found[parts[0]] = { + "version": parts[1], + "url": url.replace(f"{REPO_ROOT}/", ""), + } + return found + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + parser.add_argument("--channel", default="dist/channel") + parser.add_argument( + "--evidence", + default=str(HERE / "evidence"), + help="directory to write the dry-run evidence into", + ) + args = parser.parse_args() + + version = current_version() + subdir = host_platform() + channel = Path(args.channel) + if not channel.is_absolute(): + channel = REPO_ROOT / channel + if not (channel / subdir / "repodata.json").exists(): + raise SystemExit( + f"error: no indexed channel at {channel}/{subdir}. Run: pixi run release-build" + ) + + with tempfile.TemporaryDirectory() as tmp: + slot = Path(tmp) / "deploy" + slot.mkdir() + manifest = slot / "pixi.toml" + subprocess.run( + [ + sys.executable, + str(HERE / "deploy_manifest.py"), + "--output", + str(manifest), + "--channel", + f"file://{channel}", + "--platforms", + subdir, + ], + check=True, + cwd=REPO_ROOT, + ) + + print(f"\nResolving the robot deploy environment for {version} on {subdir} ...") + result = subprocess.run( + ["pixi", "lock", "--manifest-path", str(manifest)], + capture_output=True, + text=True, + ) + if result.returncode: + print(result.stdout) + print(result.stderr, file=sys.stderr) + raise SystemExit( + "error: the robot environment does not resolve against this build" + ) + + lock = slot / "pixi.lock" + resolved = resolved_mote_packages(lock, subdir) + + evidence = Path(args.evidence) + evidence.mkdir(parents=True, exist_ok=True) + # The manifest itself is not copied here: it is release/deploy/pixi.toml + # with the channel swapped for the local one and the platform narrowed, + # so a copy would only add an absolute path from whichever machine ran + # the build to a file that gets committed. + summary = { + "version": version, + "platform": subdir, + "manifest": "release/deploy/pixi.toml (channel overridden to dist/channel)", + "channel": "dist/channel (local, indexed)", + "packages_built": [name for name, _ in RELEASE_SET], + "resolved": resolved, + } + (evidence / f"dry-run-{subdir}.json").write_text( + json.dumps(summary, indent=2) + "\n" + ) + + print(f"\nResolved {len(resolved)} mote packages:") + for name in sorted(resolved): + print(f" {name} {resolved[name]['version']}") + missing = {name for name, _ in RELEASE_SET} - set(resolved) + if missing: + print(f"\nNOT resolved: {', '.join(sorted(missing))}", file=sys.stderr) + return 1 + print(f"\nEvidence written to {evidence.relative_to(REPO_ROOT)}/") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/release/version.py b/release/version.py new file mode 100755 index 0000000..efd442e --- /dev/null +++ b/release/version.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +"""Read, check and bump the repo-wide release version. + +Mote's first-party packages are co-developed and deployed as a set, so they +carry **one** version rather than eight independent ones: a robot runs "Mote +0.4.2", not a matrix of package versions that have to be reasoned about +together. The deploy manifest pins them all to the same string +(``release/deploy_manifest.py``), which is only meaningful if they are in fact +released in lockstep. + +The version lives in three kinds of file, and this module keeps them equal: + +* ``pixi.toml`` ``[workspace] version`` -- the human-facing source of truth +* ``/package.xml`` ```` -- what pixi-build-ros stamps into + the conda package (the per-package ``pixi.toml`` build manifests deliberately + carry no ``version``, so this is the only place the built artifact reads) +* ``/setup.py`` ``version=`` -- the Python dist metadata inside + the ament_python packages + +Third-party submodules keep their upstream versions and are not touched. + +Usage: + release/version.py show # print the current version + release/version.py check # exit non-zero if the files disagree + release/version.py set 0.2.0 # write it everywhere +""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent + +# The first-party packages released to the `mote` channel. mote_simulation is +# deliberately absent -- it is workstation-only and is never deployed (see +# docs/releasing.md), so it keeps no release version. +RELEASED_PACKAGES = [ + "mote_description", + "mote_hardware", + "mote_nav", + "mote_bringup", + "mote_perception", + "mote_tasks", + "mote_arm", + "mote_fleet", +] + +SEMVER = re.compile(r"^\d+\.\d+\.\d+$") + +# `0.1.0` on its own line, and only the first one: a +# package.xml has exactly one, but a naive global sub would also rewrite any +# nested in an block. +_PACKAGE_XML_VERSION = re.compile(r"(?m)^(\s*)([^<]*)(\s*)$") +_SETUP_PY_VERSION = re.compile(r"(?m)^(\s*version=\")([^\"]*)(\",\s*)$") +_WORKSPACE_VERSION = re.compile(r"(?m)^(version = \")([^\"]*)(\")$") + + +class VersionFile: + """One file holding the version, and the pattern that finds it.""" + + def __init__(self, path: Path, pattern: re.Pattern[str]): + self.path = path + self.pattern = pattern + + @property + def rel(self) -> str: + return str(self.path.relative_to(REPO_ROOT)) + + def read(self) -> str | None: + if not self.path.exists(): + return None + match = self.pattern.search(self.path.read_text()) + return match.group(2) if match else None + + def write(self, version: str) -> bool: + """Set the version. Returns True if the file changed.""" + text = self.path.read_text() + new_text, count = self.pattern.subn( + lambda m: f"{m.group(1)}{version}{m.group(3)}", text, count=1 + ) + if not count: + raise SystemExit(f"error: no version found in {self.rel}") + if new_text == text: + return False + self.path.write_text(new_text) + return True + + +def version_files() -> list[VersionFile]: + """Every file the release version has to agree in, workspace first.""" + files = [VersionFile(REPO_ROOT / "pixi.toml", _WORKSPACE_VERSION)] + for package in RELEASED_PACKAGES: + files.append( + VersionFile(REPO_ROOT / package / "package.xml", _PACKAGE_XML_VERSION) + ) + setup_py = REPO_ROOT / package / "setup.py" + if setup_py.exists(): # ament_cmake packages have none + files.append(VersionFile(setup_py, _SETUP_PY_VERSION)) + return files + + +def current_version() -> str: + """The workspace version -- the source of truth the others are checked against.""" + version = version_files()[0].read() + if version is None: + raise SystemExit("error: no [workspace] version in pixi.toml") + return version + + +def cmd_show(_args: argparse.Namespace) -> int: + print(current_version()) + return 0 + + +def cmd_check(_args: argparse.Namespace) -> int: + expected = current_version() + mismatched = [ + (f.rel, f.read()) for f in version_files()[1:] if f.read() != expected + ] + if mismatched: + print(f"version mismatch (pixi.toml says {expected}):", file=sys.stderr) + for rel, found in mismatched: + print(f" {rel}: {found}", file=sys.stderr) + print("\nrun: pixi run release-version set " + expected, file=sys.stderr) + return 1 + print(f"version {expected} consistent across {len(version_files())} files") + return 0 + + +def cmd_set(args: argparse.Namespace) -> int: + if not SEMVER.match(args.version): + raise SystemExit( + f"error: {args.version!r} is not MAJOR.MINOR.PATCH. Mote releases the " + "first-party packages as one semver set (docs/releasing.md)." + ) + changed = [f.rel for f in version_files() if f.write(args.version)] + if changed: + print(f"set version {args.version} in {len(changed)} file(s):") + for rel in changed: + print(f" {rel}") + else: + print(f"version already {args.version} everywhere") + print(f"\nnext: pixi run release, then tag v{args.version}") + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + sub = parser.add_subparsers(dest="command", required=True) + sub.add_parser("show", help="print the current version").set_defaults(func=cmd_show) + sub.add_parser( + "check", help="verify every file agrees with pixi.toml" + ).set_defaults(func=cmd_check) + set_parser = sub.add_parser("set", help="write a new version everywhere") + set_parser.add_argument("version", help="MAJOR.MINOR.PATCH") + set_parser.set_defaults(func=cmd_set) + args = parser.parse_args() + return args.func(args) + + +if __name__ == "__main__": + sys.exit(main())