Skip to main content

max / alloy

Report every layered component's version in alloy --version A hotfix replaces the console or shop on a machine that is already installed, so neither the image's VERSION_ID nor the console's own number says what terminal is running. `alloy --version` now prints a line per component the image names in /usr/share/alloy/components, the console excepted since it is the process answering, between the console line and the image line. Each component is asked for its own `--version` rather than read out of the image or the rpm database: a layered component is exactly the case where the image no longer knows, and build/rpm/build.sh refuses to package a binary whose `--version` disagrees with what its RPM will claim, which is what makes the cheap source the correct one. Same parse as that script, line one field two, stated in one place now. A component named but not installed is omitted rather than reported as unknown, the rule the image line already followed. That covers a server profile, which lays down no shop, and a machine whose first boot has not run yet. The probes cost a process each and clap wants long_version before it parses, so the notice is only built when argv asked for a version. A bare verb spawns nothing. New crates/alloy/src/component.rs owns the components file and the NEVRA match, moved out of pkg.rs, which now reads them from there. GO alloy 3a16a059, on the 2026-08-25 ruling that the two components version independently (142393e5).
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-25 23:23 UTC
Signed with PGP, not checked
Commit: dd0d81983b492f7163a9ab2007c5a8048cfa8930
Parent: 55d3add
4 files changed, +296 insertions, -47 deletions
@@ -36,6 +36,8 @@
36 36
37 37 **The two components version independently, ruled 2026-08-25.** The console's number comes from `crates/alloy/Cargo.toml` and shop's from its own `--version`, which is what `build/rpm/build.sh` already reads and why it reads them from different places. A hotfix to one republishes only that one. The cost is that the console and the terminal on a machine can sit at unrelated numbers, so no component answers "what version of Alloy is this"; the image does. The alternative was one shared number, which buys that single answer and pays for it by republishing an unchanged component every time the other takes a fix, and that is the normal life of a hotfix rather than an edge case.
38 38
39 + **`alloy --version` reports all three clocks, so a bug report does not have to know to ask.** The console's own version, then a line per component the image names in `/usr/share/alloy/components` other than the console itself, then the image. Each component is asked for its own `--version` rather than looked up in the image or in rpm, because a layered component is exactly the case where the image no longer knows and `build/rpm/build.sh` refuses to package a binary whose `--version` disagrees with what its RPM will claim. A component that is named but not installed is left out rather than reported as unknown, which is what a server profile (no shop) and a machine whose first boot has not laid the layers down both look like. The probes cost a process each, so they run only when the invocation asked for a version.
40 +
39 41 **What a hotfix is allowed to be.** A hotfix exists because the shipped software does not work as that release intended. Anything that changes how the software is intended to work is a release, however small the diff, and rides the ordinary path rather than this one. The second half is the channel test: a hotfix is shipped out of band, so a fix that can wait is an ordinary patch and never touches the repo. Security fixes are fixes, on the standing intent that no release be exploitable. Every hotfix is a patch bump; every intent change is at least a minor.
40 42
41 43 That definition is narrow on purpose, and here it is load-bearing rather than tidy. Every machine holding a layer is a machine carrying state outside its image, and the fewer of those there are at any moment the better.
@@ -10,6 +10,7 @@
10 10 mod bind;
11 11 mod bluetooth;
12 12 mod cli;
13 + mod component;
13 14 mod credits;
14 15 mod disk;
15 16 mod display;
@@ -72,18 +73,67 @@
72 73 /// Absent off an Alloy machine, where there is no image to have a version. See
73 74 /// [`image::version`].
74 75 ///
76 + /// # The component lines
77 + ///
78 + /// A third clock, and the reason this is not simply two versions. Since
79 + /// `cad26d5` the console and shop are laid down as layers rather than carried
80 + /// in the base, and the hotfix channel replaces one of them on a machine that
81 + /// is already installed, so neither the image's version nor the console's
82 + /// answers what terminal is on the machine. Each component is asked for its
83 + /// own, because the components version independently (Max, 2026-08-25, GO alloy
84 + /// `142393e5`) and there is no number that stands for both.
85 + ///
86 + /// See [`component`] for where the list comes from and what it costs to ask.
87 + ///
75 88 /// Built at runtime rather than `concat!`ed, which is why this is a function:
76 - /// the image version is read from a file. Interned so the `&'static str` clap
77 - /// wants outlives the call.
89 + /// the image version is read from a file and the component versions are read
90 + /// from the components themselves. Interned so the `&'static str` clap wants
91 + /// outlives the call.
78 92 fn notice() -> &'static str {
79 93 static NOTICE: std::sync::OnceLock<String> = std::sync::OnceLock::new();
80 - NOTICE.get_or_init(|| notice_text(env!("CARGO_PKG_VERSION"), image::version().as_deref()))
94 + NOTICE.get_or_init(|| {
95 + notice_text(
96 + env!("CARGO_PKG_VERSION"),
97 + &component::versions(),
98 + image::version().as_deref(),
99 + )
100 + })
101 + }
102 +
103 + /// Whether this invocation is asking for the version.
104 + ///
105 + /// [`notice`] runs a process per component, and clap wants `long_version` before
106 + /// it parses anything, so without this every `alloy net` would pay for a string
107 + /// only `alloy --version` prints. Reading argv directly is the only place the
108 + /// answer exists that early.
109 + ///
110 + /// Both spellings, because clap answers both. `-V` prints the short version and
111 + /// never reaches the notice, and it is included anyway: a person typing it is
112 + /// asking the same question, and a flag that quietly reports less than its
113 + /// sibling is the kind of difference nobody discovers until a bug report is
114 + /// missing the line that mattered.
115 + ///
116 + /// Only before the first `--`, and only as a whole argument, so a path or a
117 + /// theme id that happens to read like the flag does not spawn probes.
118 + fn wants_version<I: IntoIterator<Item = std::ffi::OsString>>(args: I) -> bool {
119 + args.into_iter()
120 + .take_while(|arg| arg != "--")
121 + .any(|arg| arg == "--version" || arg == "-V")
81 122 }
82 123
83 124 /// The assembly, split from the read so both shapes can be tested: the machine
84 125 /// that has an image and the dev host that does not.
85 - fn notice_text(console: &str, image: Option<&str>) -> String {
126 + fn notice_text(console: &str, components: &[(String, String)], image: Option<&str>) -> String {
86 127 let mut notice = String::from(console);
128 + // Components before the image, and after the console: the three are
129 + // ordered by what a support conversation asks about first, which is our own
130 + // software, and the image is the ground the other lines sit on.
131 + for (name, version) in components {
132 + notice.push('\n');
133 + notice.push_str(name);
134 + notice.push(' ');
135 + notice.push_str(version);
136 + }
87 137 if let Some(image) = image {
88 138 notice.push_str("\nimage ");
89 139 notice.push_str(image);
@@ -318,8 +368,10 @@
318 368 // Hidden from `--help`, still resolved. clap would accept the verb either
319 369 // way, and a command that silently works while being undocumented is how
320 370 // documentation becomes wrong; the match arms below say why instead.
321 - let command = profile::hide_unavailable(<Cli as clap::CommandFactory>::command(), profile)
322 - .long_version(notice());
371 + let mut command = profile::hide_unavailable(<Cli as clap::CommandFactory>::command(), profile);
372 + if wants_version(std::env::args_os()) {
373 + command = command.long_version(notice());
374 + }
323 375 let cli = Cli::from_arg_matches(&command.get_matches())?;
324 376
325 377 // Refused as well as hidden, and through the same table, so the two
@@ -535,6 +587,14 @@
535 587 /// `image`'s own tests cover.
536 588 const IMAGE: &str = "0.1 (build 20260816.143012, Fedora 43)";
537 589
590 + /// What a client machine's components read as: shop, at a version of its
591 + /// own. A literal for the same reason `IMAGE` is one — these tests are
592 + /// about the shape of the notice, and `component`'s own tests cover the
593 + /// reading.
594 + fn components() -> Vec<(String, String)> {
595 + vec![("shop".to_string(), "0.4.2".to_string())]
596 + }
597 +
538 598 /// The invariant two build steps depend on and neither can state here:
539 599 /// `Containerfile` asserts `alloy --version | grep -q '^alloy '` to prove
540 600 /// it did not ship the stub main, and `build/rpm/build.sh` reads the first
@@ -542,7 +602,7 @@
542 602 /// break silently if anything is inserted above the version.
543 603 #[test]
544 604 fn the_version_stays_alone_on_the_first_line() {
545 - let notice = notice_text("0.1.0", Some(IMAGE));
605 + let notice = notice_text("0.1.0", &components(), Some(IMAGE));
546 606 assert_eq!(notice.lines().next(), Some("0.1.0"));
547 607 }
548 608
@@ -550,7 +610,7 @@
550 610 /// machine from a rebuilt one, which is the whole reason both are printed.
551 611 #[test]
552 612 fn a_machine_with_an_image_reports_both_versions() {
553 - let notice = notice_text("0.1.0", Some(IMAGE));
613 + let notice = notice_text("0.1.0", &[], Some(IMAGE));
554 614 let mut lines = notice.lines();
555 615 assert_eq!(lines.next(), Some("0.1.0"));
556 616 assert_eq!(
@@ -559,11 +619,54 @@
559 619 );
560 620 }
561 621
622 + /// The machine the hotfix channel is for: a console at one version, a
623 + /// terminal at another, and an image older than both. All three lines, or a
624 + /// bug report cannot say which of them the reporter is running.
625 + #[test]
626 + fn a_layered_component_gets_a_line_of_its_own() {
627 + let notice = notice_text("0.1.1", &components(), Some(IMAGE));
628 + let mut lines = notice.lines();
629 + assert_eq!(lines.next(), Some("0.1.1"));
630 + assert_eq!(lines.next(), Some("shop 0.4.2"));
631 + assert_eq!(
632 + lines.next(),
633 + Some("image 0.1 (build 20260816.143012, Fedora 43)")
634 + );
635 + }
636 +
637 + /// The server profile lays down no shop, and a component named but not
638 + /// installed cannot be asked. Both arrive here as an empty list, and the
639 + /// notice says nothing rather than reporting a component it did not read.
640 + #[test]
641 + fn a_machine_with_no_components_to_report_says_nothing_about_them() {
642 + let notice = notice_text("0.1.0", &[], Some(IMAGE));
643 + assert_eq!(notice.lines().count(), 6, "{notice}");
644 + }
645 +
646 + /// Which invocations pay for the probes. `-V` is in because it is the same
647 + /// question; a bare verb is out because it would spawn a process per
648 + /// component for a string it never prints.
649 + #[test]
650 + fn only_a_version_request_builds_the_notice() {
651 + let argv = |args: &[&str]| {
652 + wants_version(
653 + args.iter()
654 + .map(std::ffi::OsString::from)
655 + .collect::<Vec<_>>(),
656 + )
657 + };
658 + assert!(argv(&["alloy", "--version"]));
659 + assert!(argv(&["alloy", "-V"]));
660 + assert!(!argv(&["alloy", "net"]));
661 + assert!(!argv(&["alloy", "config", "--version-of-something"]));
662 + assert!(!argv(&["alloy", "config", "--", "--version"]));
663 + }
664 +
562 665 /// A dev host has no image, so the line is omitted rather than filled in
563 666 /// with a placeholder that would read as a real answer.
564 667 #[test]
565 668 fn a_dev_host_reports_the_console_and_says_nothing_about_an_image() {
566 - let notice = notice_text("0.1.0", None);
669 + let notice = notice_text("0.1.0", &[], None);
567 670 assert_eq!(notice.lines().next(), Some("0.1.0"));
568 671 assert!(!notice.contains("image"), "{notice}");
569 672 }
@@ -572,7 +675,7 @@
572 675 #[test]
573 676 fn the_copyright_notice_survives_either_shape() {
574 677 for image in [Some(IMAGE), None] {
575 - let notice = notice_text("0.1.0", image);
678 + let notice = notice_text("0.1.0", &components(), image);
576 679 assert!(notice.contains("Make Creative, LLC"), "{notice}");
577 680 assert!(notice.contains("License MIT"), "{notice}");
578 681 }
@@ -70,6 +70,7 @@
70 70 use serde::Deserialize;
71 71
72 72 use crate::cli::{CommandLog, Effect, Invocation};
73 + use crate::component::{self, is_component};
73 74 use crate::shell::{Confirm, Flow, View, block_title, truncate};
74 75 use crate::stale::{self, Staleness};
75 76
@@ -1207,42 +1208,6 @@
1207 1208 }
1208 1209 }
1209 1210
1210 - /// Where the image names its own components, one per line.
1211 - ///
1212 - /// Written at build time rather than hardcoded here, because the profile
1213 - /// decides the list: `server` has no compositor and never lays shop down
1214 - /// (Containerfile, the components block). Reading it means a profile that ships
1215 - /// a different set does not make this screen lie about whose package is whose.
1216 - const COMPONENTS_PATH: &str = "/usr/share/alloy/components";
1217 -
1218 - /// Alloy's own components, as this image names them.
1219 - ///
1220 - /// `None` when the file is absent, which is not a failure and is not rare: a
1221 - /// development box, a Fedora ostree machine that is not Alloy, or an image
1222 - /// built before the component flip (GO alloy `d866e125`). The caller says less
1223 - /// in that case rather than falling back to a guess, because the guess here is
1224 - /// labelling somebody else's package as ours.
1225 - fn components() -> Option<Vec<String>> {
1226 - let raw = std::fs::read_to_string(COMPONENTS_PATH).ok()?;
1227 - let names: Vec<String> = raw.split_whitespace().map(str::to_string).collect();
1228 - (!names.is_empty()).then_some(names)
1229 - }
1230 -
1231 - /// Whether a layered entry is the component `name`.
1232 - ///
1233 - /// Two forms, because a request is recorded under the string that installed it.
1234 - /// `alloy-layer-components.service` installs by bare name, so that is what an
1235 - /// ordinary Alloy machine reports, and NEVRA is what a machine somebody layered
1236 - /// by hand reports. The version has to start with a digit for the second form
1237 - /// to match, so `alloy-utils` is not read as `alloy` wearing a version.
1238 - fn is_component(entry: &str, name: &str) -> bool {
1239 - entry == name
1240 - || entry
1241 - .strip_prefix(name)
1242 - .and_then(|rest| rest.strip_prefix('-'))
1243 - .is_some_and(|rest| rest.starts_with(|c: char| c.is_ascii_digit()))
1244 - }
1245 -
1246 1211 /// What the booted deployment's layering means, as words.
1247 1212 ///
1248 1213 /// Empty when nothing is layered, which is the answer on every non-Alloy ostree
@@ -1482,7 +1447,7 @@
1482 1447 has_rpm: Rpm::present(),
1483 1448 rpm: None,
1484 1449 has_dnf: stale::available(),
1485 - components: components(),
1450 + components: component::names(),
1486 1451 stale: None,
1487 1452 last_checked: stale::last_checked(),
1488 1453 };
@@ -1,0 +1,179 @@
1 + //! Alloy's own components on this machine, and the version each one reports.
2 + //!
3 + //! A component is a piece of Make Creative's own software the image lays down
4 + //! as a layer rather than carrying in the base: the console and shop today. The
5 + //! hotfix channel exists to replace one of them on a machine that is already
6 + //! installed (docs/STACK.md, Hotfixes), so what a component is running is not
7 + //! answerable from the image it arrived on and has to be asked of the component.
8 + //!
9 + //! The two version rules this module implements, both ruled rather than chosen
10 + //! here:
11 + //!
12 + //! - **The components version independently** (Max, 2026-08-25, GO alloy
13 + //! `142393e5`). The console's number comes from its own crate and shop's from
14 + //! its own `--version`, and a hotfix to one republishes only that one. So this
15 + //! asks each component separately and composes nothing.
16 + //! - **A binary's own `--version` is the statement of record.**
17 + //! `build/rpm/build.sh` refuses to package a binary whose `--version`
18 + //! disagrees with the version the RPM will claim, precisely so that asking the
19 + //! binary and asking rpm cannot give different answers. Asking the binary is
20 + //! the cheaper of the two and needs no rpm database, so that is what this
21 + //! does, and the packaging guard is what makes the choice free.
22 + //!
23 + //! <!-- wiki: alloy-console -->
24 +
25 + use crate::cli::child_command;
26 +
27 + /// The console itself, as the components file names it.
28 + ///
29 + /// Excluded from [`versions`] rather than probed: `alloy --version` is where
30 + /// that list is printed, and the process asking is the answer.
31 + pub(crate) const CONSOLE: &str = "alloy";
32 +
33 + /// Where the image names its own components, one per line.
34 + ///
35 + /// Written at build time rather than hardcoded here, because the profile
36 + /// decides the list: `server` has no compositor and never lays shop down
37 + /// (Containerfile, the components block). Reading it means a profile that ships
38 + /// a different set does not make a screen lie about whose package is whose.
39 + const COMPONENTS_PATH: &str = "/usr/share/alloy/components";
40 +
41 + /// Alloy's own components, as this image names them.
42 + ///
43 + /// `None` when the file is absent, which is not a failure and is not rare: a
44 + /// development box, a Fedora ostree machine that is not Alloy, or an image
45 + /// built before the component flip (GO alloy `d866e125`). The caller says less
46 + /// in that case rather than falling back to a guess, because the guess here is
47 + /// labelling somebody else's package as ours.
48 + pub(crate) fn names() -> Option<Vec<String>> {
49 + parse(&std::fs::read_to_string(COMPONENTS_PATH).ok()?)
50 + }
51 +
52 + /// The parse, split from the read so it can be tested without a components
53 + /// file on the host running the tests.
54 + fn parse(raw: &str) -> Option<Vec<String>> {
55 + let names: Vec<String> = raw.split_whitespace().map(str::to_string).collect();
56 + (!names.is_empty()).then_some(names)
57 + }
58 +
59 + /// Whether a layered entry is the component `name`.
60 + ///
61 + /// Two forms, because a request is recorded under the string that installed it.
62 + /// `alloy-layer-components.service` installs by bare name, so that is what an
63 + /// ordinary Alloy machine reports, and NEVRA is what a machine somebody layered
64 + /// by hand reports. The version has to start with a digit for the second form
65 + /// to match, so `alloy-utils` is not read as `alloy` wearing a version.
66 + pub(crate) fn is_component(entry: &str, name: &str) -> bool {
67 + entry == name
68 + || entry
69 + .strip_prefix(name)
70 + .and_then(|rest| rest.strip_prefix('-'))
71 + .is_some_and(|rest| rest.starts_with(|c: char| c.is_ascii_digit()))
72 + }
73 +
74 + /// Every component other than the console, with the version it reports.
75 + ///
76 + /// Empty off an Alloy machine, on a profile that lays down nothing but the
77 + /// console, and for any component that is named but not installed. Each of
78 + /// those is a real state and none of them is an error: a component that cannot
79 + /// be asked is left out of the list rather than listed as unknown, on the same
80 + /// rule the image line follows in [`crate::notice_text`].
81 + ///
82 + /// **Costs a process per component**, which is why the caller is
83 + /// [`crate::wants_version`] and not every invocation of the console.
84 + pub(crate) fn versions() -> Vec<(String, String)> {
85 + let Some(names) = names() else {
86 + return Vec::new();
87 + };
88 + names
89 + .into_iter()
90 + .filter(|name| name != CONSOLE)
91 + .filter_map(|name| {
92 + let version = reported(&name)?;
93 + Some((name, version))
94 + })
95 + .collect()
96 + }
97 +
98 + /// What `<name> --version` says, or `None` if it cannot be asked.
99 + ///
100 + /// Through [`child_command`] because that is the crate's only `Command::new`
101 + /// (`tests/sbin_path.rs` enforces it), and because the component is found on the
102 + /// child `PATH` the same way every other tool the console runs is.
103 + ///
104 + /// Not routed through [`crate::cli::Invocation`], which is the shape for a
105 + /// command a view runs and logs: nothing here is a view, and the log pane this
106 + /// crate commits to showing does not exist during argument parsing.
107 + fn reported(name: &str) -> Option<String> {
108 + let output = child_command(name).arg("--version").output().ok()?;
109 + if !output.status.success() {
110 + return None;
111 + }
112 + version_field(std::str::from_utf8(&output.stdout).ok()?).map(str::to_string)
113 + }
114 +
115 + /// The version out of a `--version` first line, as the rest of the build reads
116 + /// it: line one, field two.
117 + ///
118 + /// The same parse as `build/rpm/build.sh` (`head -1 | awk '{print $2}'`), and
119 + /// deliberately so. If this and the packaging script read a component
120 + /// differently, the version a machine reports and the version its RPM claims
121 + /// can disagree, and that disagreement is invisible afterwards — which is the
122 + /// one mistake that script exists to make impossible.
123 + fn version_field(stdout: &str) -> Option<&str> {
124 + stdout.lines().next()?.split_whitespace().nth(1)
125 + }
126 +
127 + #[cfg(test)]
128 + mod tests {
129 + use super::*;
130 +
131 + /// The near-miss the NEVRA match has to refuse: a package whose name merely
132 + /// starts with ours is not ours.
133 + #[test]
134 + fn a_package_named_after_ours_is_not_ours() {
135 + assert!(is_component("alloy-0.1.0-1.fc43.x86_64", "alloy"));
136 + assert!(is_component("alloy", "alloy"));
137 + assert!(!is_component("alloy-utils", "alloy"));
138 + assert!(!is_component("alloys", "alloy"));
139 + assert!(!is_component("shop", "alloy"));
140 + }
141 +
142 + /// The client profile's file, and the server's, which names one component.
143 + #[test]
144 + fn the_components_file_is_read_as_whitespace_separated_names() {
145 + assert_eq!(
146 + parse("alloy\nshop\n"),
147 + Some(vec!["alloy".to_string(), "shop".to_string()])
148 + );
149 + assert_eq!(parse("alloy\n"), Some(vec!["alloy".to_string()]));
150 + }
151 +
152 + /// An empty file is a machine that named no components, which reads the
153 + /// same as having no file: say nothing rather than report an empty set as
154 + /// an answer.
155 + #[test]
156 + fn a_file_with_no_names_in_it_is_not_an_answer() {
157 + assert_eq!(parse(""), None);
158 + assert_eq!(parse(" \n\n"), None);
159 + }
160 +
161 + /// The shape every component the channel packages has to have, because the
162 + /// packaging script reads exactly this and refuses anything else.
163 + #[test]
164 + fn the_version_is_the_first_lines_second_field() {
165 + assert_eq!(version_field("shop 0.4.2\n"), Some("0.4.2"));
166 + assert_eq!(
167 + version_field("shop 0.4.2\nCopyright (c) 2026\n"),
168 + Some("0.4.2")
169 + );
170 + }
171 +
172 + /// A binary that answers something else is not read for a version at all.
173 + /// Better to omit the component than to print the first word of an error.
174 + #[test]
175 + fn a_first_line_with_no_second_field_reports_nothing() {
176 + assert_eq!(version_field("shop\n"), None);
177 + assert_eq!(version_field(""), None);
178 + }
179 + }