ADR-150: Version-truth and downgrade-safe self-update¶
Context¶
ways update (the self-update command, ADR-142's projection lifecycle) refreshes
the app source and its binaries "pre-built first": it downloads the latest
GitHub Release binary and only falls back to building from source when no
toolchain is present. This is correct for an end user pinned to a release — but
it silently downgraded a live install in practice:
- The app checkout tracks
main. At the time of the failuremainwas 78 commits ahead of theways-v1.0.0tag — those 78 commits included the entiresettingscommand andways updateitself. tools/ways-cli/Cargo.tomlhad stayed atversion = "1.0.0"across all 78 commits, and the newest published release was alsoways-v1.0.0. So the pre-built binary and the source reported the same version string while being 78 commits apart.ways updatedownloaded the staleways-v1.0.0binary over the current one.ways updateandways settingsvanished (unrecognized subcommand), and a secondways updatewas impossible — the command that would fix it had been removed by the update.
The machinery to avoid this already exists; the gap is version-truth, not tooling:
- The release pipeline is complete.
.github/workflows/build-ways.yml(and the siblingbuild-attend/build-way-embedworkflows) trigger onways-v*tags, build all four platforms, and the tag-gatedreleasejob runsgh release create. Cutting a release isbump + tag + push; CI does the rest. - The binary already bakes provenance.
build.rssetsWAYS_COMMITfromgit rev-parse --short HEAD, andbanner.rsshowsv1.0.0 (c595437).
But that provenance never reaches the surfaces that decide anything:
ways --versionis wired to clap'sversion=CARGO_PKG_VERSIONalone (1.0.0), with no commit.download-ways.sh's "already installed" check and every human sanity-check see only the frozen string. The one place the commit appears — the bare-invocation banner — is human-only and unread by tooling.- Nothing compares versions before replacing a binary.
ways update'srefresh_componentrenames-then-reverts on build failure, but a successful download of an older binary is treated as success. There is no "is this candidate actually newer than what I'm replacing?" gate.
The result: staleness is invisible where it counts, and the self-updater will
downgrade whenever the tracked source is ahead of the latest cut release — which,
for a checkout that follows main, is the normal state between releases.
Decision¶
Make the version truthful and staleness-detectable, and make self-update refuse to move backward — leveraging the existing tag→CI→release pipeline rather than adding a new one. Four changes:
-
Bake
git describe, not just the short hash. Extendbuild.rsto emitWAYS_BUILD = git describe --tags --always --dirty(e.g.ways-v1.0.0-78-gc595437) alongsideWAYS_COMMIT. This single string encodes the nearest release tag, the commits-ahead count, the commit, and a dirty flag — everything needed to order two builds. -
Surface it in
ways --version. Set clap'slong_versionsoways --versionprints the full provenance (ways 1.0.0 (ways-v1.0.0-78-gc595437)), making a dev build visibly distinct from a release build to both humans and scripts. The banner is unaffected. -
A downgrade guard in
ways update. Before installing a candidate pre-built binary, compare its embeddedWAYS_BUILDagainst the pulled source'sgit describe. Install the pre-built only if it is at least as new as the source (same commit, or the source is an ancestor of the release). Otherwise: - toolchain present → build from source (the checkout is the authority and can't be behind itself);
-
no toolchain → keep the current binary and warn loudly ("a pre-built matching this source hasn't been published yet; install a toolchain or wait for the release"). Never replace a binary with an older one. This is the belt-and-suspenders that keeps
ways updatesafe between releases, independent of whether anyone remembered to cut one. -
A release-cut helper so the version bump can't be forgotten. The root cause was human: 78 commits merged with no bump and no tag. Add a single entry point —
scripts/release.sh <component> <patch|minor|major>(surfaced asmake cut-release COMPONENT=ways LEVEL=patch) — that bumps the componentCargo.tomlversion, commits it, creates the<component>-vX.Y.Ztag, and pushes. CI takes it from there. The policy: a release is cut frommainwhenever a user-affecting change to a shipped binary lands (not every PR, but no long silent runs). Between releases,git describe— now surfaced and guarded — carries the truth, so a missed release degrades to "build from source / keep current," never to a downgrade.
Semver stays per-component (ways-vX.Y.Z, attend-vX.Y.Z, …), matching the
existing tag scheme and independent CI workflows.
Consequences¶
Positive¶
- The self-updater can never silently downgrade: the worst case between releases is "built from source" or "kept current with a warning," both of which leave a working, current-or-newer binary.
- Staleness is legible everywhere —
ways --version,download-ways.sh, CI logs, bug reports — because the build string names its exact provenance. - The release pipeline that already exists gets used on a discipline, closing the gap that let 78 commits sit unpublished.
git describeordering is free and reliable — no version-string parsing games, no dependence on anyone bumping Cargo.toml for correctness (the bump is for human-facing semver; the guard keys on commit ancestry).
Negative¶
build.rsnow depends ongit describesucceeding in the build environment; a tarball build with no.gityieldsWAYS_BUILD = unknown(handled: the guard treatsunknownas "cannot prove newer" → build/keep-current, never downgrade).- The downgrade guard needs the source checkout's
git describeat update time — fine for the app checkout (always a git clone per ADR-142), and the guard degrades safely when it can't be computed. - One more release step to remember — mitigated by the
make cut-releasehelper, which makes the correct path the easy path.
Neutral¶
- Requires touching
build.rs,main.rs(claplong_version),update.rs(refresh_componentgains the version comparison), and a newscripts/release.sh+make cut-releasetarget — the build slice tracked separately from this ADR. - The
attend/attend-chatbinaries still have no published pre-builts; the guard's "no candidate newer than source → build/keep" path already covers them (they are toolchain-gated today), so nothing regresses.
Alternatives Considered¶
- Always build from source when a toolchain is present (drop pre-built-first). Rejected: it discards the explicit "not everyone has build tools" requirement that motivated pre-built-first, and it's slower for the common case where the source is a released tag. The downgrade guard achieves the same safety while keeping download-first for users on releases.
- Compare
CARGO_PKG_VERSIONstrings only (bump-per-PR discipline). Rejected as the primary mechanism: it's exactly what failed — a human forgot to bump, and string equality can't see commit ancestry. Version bumps remain for human-facing semver, but correctness rides ongit describe, which can't be forgotten. - Auto-cut a release on every merge to
main(CI bumps + tags). Rejected as over-complex for now ("only complexify to the amount necessary"): it turns every merge into a published release with version churn and 4-platform builds. Themake cut-releasehelper keeps cutting cheap and deliberate; auto-cut can be revisited if the cadence proves too manual. - Embed a monotonic build number instead of
git describe. Rejected: it needs external state (a counter), wheregit describederives ordering from the repo itself for free and is human-legible.
Amendment (2026-07-05): the downgrade guard does not apply to --ref¶
The guard above governs the release channel: ways update pulls the tracked
branch and must never replace a binary with an older published one. ways update
--ref <branch|tag|sha> (ADR-142's amendment) is a different lifecycle — an
explicit pin to a chosen ref, built from source — and the guard is
intentionally bypassed there, for two reasons:
- Download-first cannot apply. An unpublished ref has no GitHub Release
binary, so
--refalways builds from source. There is no downloaded candidate to compare — which is the only thing the guard gates. - "Never move backward" is the wrong invariant for an explicit pin. The guard
exists because a channel update should be monotonic. Choosing to deploy a
specific commit — including one behind the current build, to reproduce or
bisect — is a deliberate act, not an accidental downgrade. The guard's job is to
stop silent regressions; a named
--refis neither silent nor accidental.
The safety that remains is structural, not guard-based: --ref still touches only
app-scope ($XDG_DATA plus the regenerable projection) per ADR-142 §5, and
returning to the release channel is one command (ways update --ref main), after
which the guard governs again as normal.