Skip to content

Commit 5e46849

Browse files
committed
getting kuma needed a compiler, and the binary could not say which one it was
The README's own install section ended by telling people to build kuma in a container and copy the binary out. That is a fair description of the work, and it lands hardest on exactly the machines most likely to want this: an image-based desktop has podman and no toolchain, which is the same reason kuma exists. So releases now publish one. It is a static musl build, and musl here is about linking rather than any target system running it: the artifact has no interpreter and no shared objects, so it never touches Fedora's glibc or anyone else's. A dynamic glibc build would instead carry a version floor from whatever runner produced it, working on a current Fedora today and quietly refusing to start on an older machine after the runner image moves. Static linking is only safe because kuma makes no NSS calls and no in-process network calls; everything external is a subprocess, and podman and bootc remain the host's own dynamically linked binaries. That constraint is worth preserving, because in-process HTTP or a user lookup would silently break this. The second half is knowing what you have. `--version` reported `0.1.0`, which answers a question nobody asks, while the question that has twice cost real time here is "does this binary contain the change I just made". The niri portal fix looked broken because the installed binary predated the commit, and the wallpaper swap would have baked the old asset for the same reason. Both times the binary looked fine and said nothing. build.rs now stamps the commit and its date, and appends `-dirty` when the tree had changes the sha does not describe. Untracked files count, since an asset that exists only locally is compiled in exactly like a tracked one. Getting the rerun rules right matters more than it looks: emitting any rerun-if-changed opts out of cargo's default of watching every file, so the git internals and the sources both have to be named. Miss the first and a fresh commit keeps the old sha, miss the second and `-dirty` outlives the edit that earned it. The asset name carries no version. That is what lets the README hold a `releases/latest/download/` URL that stays correct, and nothing is lost because the tag has the version and the binary now reports it. A test pins the two together, since a rename on either side leaves an install command that 404s for every reader and is invisible from in here. Version moves to 0.2.0. 0.x already says what needs saying about stability, and the sha carries the rest. The release job runs the suite before publishing. ci.yml runs on the same push but nothing makes this job wait for it, so without that gate a red tree still ships a rolling binary. Testing on the release target instead of the host's is one compile rather than two and exercises the artifact actually being shipped. Verified before pushing, since no CI gate here has ever executed before its first real run: the musl binary is static-pie with no dynamic dependencies and runs on this laptop outside its build container, passing `check` against a real declaration and a full `--json` state probe through podman; both workflow shell steps were rendered from the YAML and executed against a fake `gh`, with the tag-mismatch guard confirmed firing on a wrong tag; shellcheck clean on both. 133 tests on the musl target, fmt and clippy clean.
1 parent 05f0939 commit 5e46849

7 files changed

Lines changed: 307 additions & 20 deletions

File tree

‎.github/workflows/release.yml‎

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
name: release
2+
3+
on:
4+
# A tag is a release: it gets a version number, release notes, and a URL
5+
# that keeps meaning the same thing a year from now.
6+
push:
7+
tags:
8+
- "v*"
9+
# main is the rolling channel. kuma is consumed by rebuilding constantly,
10+
# so waiting for a tag to get a usable binary would mean the published
11+
# one is nearly always behind the tree it is meant to build from.
12+
branches:
13+
- main
14+
workflow_dispatch:
15+
16+
permissions:
17+
contents: write
18+
# Keyless signing: cosign trades this token for a short-lived certificate
19+
# instead of anyone holding a private key.
20+
id-token: write
21+
22+
env:
23+
CARGO_TERM_COLOR: always
24+
TARGET: x86_64-unknown-linux-musl
25+
26+
jobs:
27+
release:
28+
runs-on: ubuntu-latest
29+
steps:
30+
- uses: actions/checkout@v7
31+
32+
- name: work out what this build is
33+
id: what
34+
run: |
35+
set -euo pipefail
36+
version=$(cargo metadata --no-deps --format-version 1 \
37+
| jq -r '.packages[] | select(.name == "kuma") | .version')
38+
# The asset name carries no version on purpose. A versioned
39+
# filename would make releases/latest/download/<name> change
40+
# every release, so the README could never hold a durable URL,
41+
# and the version is not lost: the tag has it and the binary
42+
# reports it.
43+
if [ "${GITHUB_REF_TYPE}" = "tag" ]; then
44+
# A tag that disagrees with Cargo.toml would publish a binary
45+
# whose own --version contradicts the release it sits in. That
46+
# is the exact class of lie the build stamp exists to prevent,
47+
# so it fails here rather than shipping.
48+
if [ "${GITHUB_REF_NAME}" != "v${version}" ]; then
49+
echo "tag ${GITHUB_REF_NAME} does not match Cargo.toml version ${version}" >&2
50+
exit 1
51+
fi
52+
{
53+
echo "tag=v${version}"
54+
echo "name=kuma-${TARGET}"
55+
echo "prerelease=false"
56+
echo "title=kuma v${version}"
57+
} >> "$GITHUB_OUTPUT"
58+
else
59+
{
60+
echo "tag=latest"
61+
echo "name=kuma-${TARGET}"
62+
echo "prerelease=true"
63+
echo "title=Rolling build from main"
64+
} >> "$GITHUB_OUTPUT"
65+
fi
66+
67+
- uses: dtolnay/rust-toolchain@stable
68+
with:
69+
targets: x86_64-unknown-linux-musl
70+
- uses: Swatinem/rust-cache@v2
71+
72+
# ci.yml runs on this same push, but nothing makes this job wait for
73+
# it, so without a gate here a red tree still publishes a binary.
74+
# Testing on the release target rather than the host's costs nothing
75+
# extra: it is one compile instead of two, and it exercises the
76+
# artifact actually being shipped.
77+
- name: test
78+
run: cargo test --locked --target "$TARGET"
79+
80+
# Static by construction, not by flag: kuma is pure Rust with no NSS
81+
# calls and no in-process networking, so a musl build has nothing to
82+
# dynamically link. That is what lets it run on an image-based
83+
# desktop that has podman and no toolchain, which is the machine
84+
# most likely to want it.
85+
- name: build
86+
run: cargo build --locked --release --target "$TARGET"
87+
88+
- name: package
89+
id: pkg
90+
run: |
91+
set -euo pipefail
92+
name='${{ steps.what.outputs.name }}'
93+
cp "target/${TARGET}/release/kuma" "$name"
94+
strip "$name"
95+
sha256sum "$name" > "${name}.sha256"
96+
# Prove the artifact runs before publishing it. A binary that
97+
# cannot answer --version on a clean runner is not a release.
98+
"./$name" --version
99+
echo "name=$name" >> "$GITHUB_OUTPUT"
100+
101+
- uses: sigstore/cosign-installer@v3
102+
103+
- name: sign
104+
run: |
105+
set -euo pipefail
106+
cosign sign-blob --yes \
107+
--output-signature '${{ steps.pkg.outputs.name }}.sig' \
108+
--output-certificate '${{ steps.pkg.outputs.name }}.pem' \
109+
'${{ steps.pkg.outputs.name }}'
110+
111+
# The rolling release is one moving target, so its old assets and tag
112+
# are cleared first. Deleting the tag matters: gh reuses an existing
113+
# one and the release would keep pointing at the commit it was first
114+
# cut from.
115+
- name: clear the rolling release
116+
if: steps.what.outputs.tag == 'latest'
117+
run: |
118+
gh release delete latest --yes --cleanup-tag || true
119+
env:
120+
GH_TOKEN: ${{ github.token }}
121+
122+
- name: publish
123+
run: |
124+
set -euo pipefail
125+
gh release create '${{ steps.what.outputs.tag }}' \
126+
--title '${{ steps.what.outputs.title }}' \
127+
--notes "$(cat <<'NOTES'
128+
Static `x86_64` binary, no runtime dependencies.
129+
130+
```console
131+
$ curl -LO ${{ github.server_url }}/${{ github.repository }}/releases/download/${{ steps.what.outputs.tag }}/${{ steps.pkg.outputs.name }}
132+
$ chmod +x ${{ steps.pkg.outputs.name }} && sudo mv ${{ steps.pkg.outputs.name }} /usr/local/bin/kuma
133+
```
134+
135+
Verify it came from this workflow:
136+
137+
```console
138+
$ cosign verify-blob \
139+
--certificate ${{ steps.pkg.outputs.name }}.pem \
140+
--signature ${{ steps.pkg.outputs.name }}.sig \
141+
--certificate-identity-regexp '^${{ github.server_url }}/${{ github.repository }}/' \
142+
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
143+
${{ steps.pkg.outputs.name }}
144+
```
145+
146+
Built from `${{ github.sha }}`.
147+
NOTES
148+
)" \
149+
${{ steps.what.outputs.prerelease == 'true' && '--prerelease' || '' }} \
150+
--target '${{ github.sha }}' \
151+
'${{ steps.pkg.outputs.name }}' \
152+
'${{ steps.pkg.outputs.name }}.sha256' \
153+
'${{ steps.pkg.outputs.name }}.sig' \
154+
'${{ steps.pkg.outputs.name }}.pem'
155+
env:
156+
GH_TOKEN: ${{ github.token }}

‎Cargo.lock‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "kuma"
3-
version = "0.1.0"
3+
version = "0.2.0"
44
edition = "2021"
55
# Set by sha-crypt 0.6, the highest floor anything here asks for. Without
66
# it, an older toolchain fails somewhere inside a dependency instead of
@@ -10,6 +10,13 @@ description = "Declarative, atomic Fedora bootc system"
1010
license = "MIT"
1111
repository = "https://github.com/Letdown2491/kuma-linux"
1212

13+
# cargo-binstall fetches the release asset instead of compiling. Its
14+
# default URL patterns expect the version in the filename, which kuma's
15+
# assets deliberately omit, so the pattern is spelled out.
16+
[package.metadata.binstall]
17+
pkg-url = "{ repo }/releases/download/v{ version }/kuma-{ target }"
18+
pkg-fmt = "bin"
19+
1320
[dependencies]
1421
anyhow = "1"
1522
clap = { version = "4", features = ["derive"] }

‎README.md‎

Lines changed: 27 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -31,35 +31,46 @@ try a declaration in `kuma vm` before a machine you depend on.
3131
## Install
3232

3333
Kuma is one self-contained binary: the wallpaper, the greeter config, and
34-
every desktop asset are compiled into it. Building it needs a Rust
35-
toolchain at 1.85 or newer and a linker.
34+
every desktop asset are compiled into it. The published build is static, so
35+
it needs nothing installed alongside it. This matters on the machines most
36+
likely to want kuma, which tend to have podman and no compiler.
37+
38+
```console
39+
$ curl -LO https://github.com/Letdown2491/kuma-linux/releases/latest/download/kuma-x86_64-unknown-linux-musl
40+
$ chmod +x kuma-x86_64-unknown-linux-musl
41+
$ sudo mv kuma-x86_64-unknown-linux-musl /usr/local/bin/kuma
42+
```
43+
44+
Every release is signed. Each one carries the `cosign verify-blob` command
45+
that checks it came from this repository's release workflow.
46+
47+
The `latest` prerelease is the same binary built from the current `main`,
48+
for tracking the tree between releases.
49+
50+
Building it yourself needs a Rust toolchain at 1.85 or newer and a linker:
3651

3752
```console
3853
$ cargo install --git https://github.com/Letdown2491/kuma-linux --locked
3954
```
4055

41-
Or clone it, which also gets you the example declarations and the smoke
42-
tests:
56+
Keep `--locked`. Without it cargo ignores the committed `Cargo.lock` and
57+
resolves dependencies fresh, so you get versions nobody tested.
58+
59+
Cloning also gets you the example declarations and the smoke tests:
4360

4461
```console
4562
$ git clone https://github.com/Letdown2491/kuma-linux
4663
$ cd kuma-linux && cargo install --path .
4764
```
4865

49-
Either way the binary is `kuma`.
50-
51-
Keep `--locked` on the first form. Without it cargo ignores the committed
52-
`Cargo.lock` and resolves dependencies fresh, so you get versions nobody
53-
tested.
66+
`kuma --version` reports the commit it was built from, and says `-dirty` if
67+
that tree had uncommitted changes. Worth checking when a change you just
68+
made does not show up in the image.
5469

5570
**What needs what.** `init`, `check`, `generate`, and `build` need only
5671
podman. `switch`, `update`, `rollback`, and `doctor` need to be running on a
5772
bootc machine. `vm` and `iso` need KVM and sudo.
5873

59-
If you already run an image-based desktop, the obvious place to want this,
60-
you probably have podman and no compiler. Build kuma in a toolbox or a
61-
container and copy the binary out.
62-
6374
## Quick start
6475

6576
```console
@@ -165,10 +176,9 @@ not obvious from them:
165176

166177
What kuma doesn't do:
167178

168-
- **No published images.** Everything is built locally. An image isn't
169-
`bootc switch`-able from anywhere, and nothing is signed yet.
170-
- **No prebuilt `kuma` binary.** Getting the tool needs a Rust toolchain
171-
and a linker.
179+
- **No published images.** Every image is built locally, so none is
180+
`bootc switch`-able from anywhere, and no image is signed. The `kuma`
181+
binary is published and signed; the images it builds are not.
172182
- **No offsite backup.** `[snapshots]` survives a mistake, not a dead
173183
disk. Blocked on where a repository credential lives, since it cannot be
174184
the declaration.

‎build.rs‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
//! Stamps the checkout's identity into the binary.
2+
//!
3+
//! `kuma --version` reporting only `0.2.0` would answer the least useful
4+
//! question. Twice now a change has looked broken because the installed
5+
//! binary predated it: the niri portal fix appeared not to work, and a
6+
//! wallpaper swap would have silently baked the old asset. Both times the
7+
//! binary looked fine and said nothing. The commit it was built from, and
8+
//! whether the tree was clean at the time, is the thing worth carrying.
9+
//!
10+
//! Both values degrade to "unknown" rather than failing the build. A
11+
//! release tarball has no `.git`, and a build from one is still valid.
12+
13+
use std::path::Path;
14+
use std::process::Command;
15+
16+
fn main() {
17+
// Emitting any rerun-if-changed opts out of cargo's default, which is
18+
// to rerun on any file in the package. So the git internals AND the
19+
// sources have to be named: HEAD and the ref catch a commit or a
20+
// branch switch, the sources catch the edits that make a tree dirty.
21+
// Miss the first group and a fresh commit keeps the old sha; miss the
22+
// second and "-dirty" outlives the edit that earned it.
23+
println!("cargo:rerun-if-changed=.git/HEAD");
24+
println!("cargo:rerun-if-changed=.git/index");
25+
if let Some(head_ref) = git(&["rev-parse", "--symbolic-full-name", "HEAD"]) {
26+
let path = format!(".git/{head_ref}");
27+
if Path::new(&path).exists() {
28+
println!("cargo:rerun-if-changed={path}");
29+
}
30+
}
31+
for path in ["src", "assets", "Cargo.toml", "Cargo.lock"] {
32+
println!("cargo:rerun-if-changed={path}");
33+
}
34+
35+
let sha = match git(&["rev-parse", "--short=7", "HEAD"]) {
36+
Some(sha) if dirty() => format!("{sha}-dirty"),
37+
Some(sha) => sha,
38+
None => "unknown".to_string(),
39+
};
40+
// The commit's own date, not the build's: two builds of one commit
41+
// describe the same code, and saying otherwise invites the belief
42+
// that a rebuild changed something.
43+
let date = git(&["log", "-1", "--format=%cd", "--date=short"])
44+
.unwrap_or_else(|| "unknown".to_string());
45+
46+
println!("cargo:rustc-env=KUMA_BUILD_SHA={sha}");
47+
println!("cargo:rustc-env=KUMA_BUILD_DATE={date}");
48+
}
49+
50+
/// Trimmed stdout of a successful `git`, or None if git is missing, this
51+
/// is not a repository, or the command failed.
52+
fn git(args: &[&str]) -> Option<String> {
53+
let out = Command::new("git").args(args).output().ok()?;
54+
if !out.status.success() {
55+
return None;
56+
}
57+
let text = String::from_utf8(out.stdout).ok()?.trim().to_string();
58+
(!text.is_empty()).then_some(text)
59+
}
60+
61+
/// Whether the working tree has changes the sha does not describe.
62+
/// Untracked files count: an asset that exists only locally is baked into
63+
/// the binary exactly like a tracked one.
64+
fn dirty() -> bool {
65+
git(&["status", "--porcelain"]).is_some_and(|s| !s.is_empty())
66+
}

‎src/config.rs‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,41 @@ mod tests {
430430
assert!(hashed("hunter2").is_err(), "a plaintext password is not a hash");
431431
}
432432

433+
/// The README tells people to download one filename and the release
434+
/// workflow publishes another, and nothing else connects the two. A
435+
/// rename on either side leaves an install command that 404s, which
436+
/// is invisible here and immediate for anyone following the front
437+
/// door. So the workflow's asset name is the assertion.
438+
///
439+
/// The name is deliberately unversioned, which is what lets the
440+
/// README hold a `releases/latest/download/` URL at all; this pins
441+
/// that too, since putting the version back would break the link
442+
/// silently one release later.
443+
#[test]
444+
fn the_readme_downloads_what_the_release_workflow_publishes() {
445+
let workflow = std::fs::read_to_string(concat!(
446+
env!("CARGO_MANIFEST_DIR"),
447+
"/.github/workflows/release.yml"
448+
))
449+
.unwrap();
450+
let target = workflow
451+
.lines()
452+
.find_map(|l| l.trim().strip_prefix("TARGET: "))
453+
.expect("the workflow names a build target");
454+
let asset = format!("kuma-{target}");
455+
assert!(
456+
workflow.contains("echo \"name=kuma-${TARGET}\""),
457+
"the workflow builds its asset name from TARGET, unversioned"
458+
);
459+
460+
let readme =
461+
std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/README.md")).unwrap();
462+
assert!(
463+
readme.contains(&format!("releases/latest/download/{asset}")),
464+
"README should download {asset} from the latest release"
465+
);
466+
}
467+
433468
/// The README's example declaration is the first thing anyone copies,
434469
/// so it is held to the same bar as the committed examples. It has
435470
/// been wrong before: it showed `password_hash = '...'` for months,

‎src/main.rs‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,21 @@ use std::path::{Path, PathBuf};
1919
pub(crate) const DEFAULT_TAG: &str = "localhost/kuma:latest";
2020
const BIB_IMAGE: &str = "quay.io/centos-bootc/bootc-image-builder:latest";
2121

22+
/// What `--version` prints. The number alone cannot answer "is this
23+
/// binary the one that has my last change in it", which is the question
24+
/// that has actually cost time here. See build.rs for where the stamp
25+
/// comes from and why a dirty tree is called out.
26+
const VERSION: &str = concat!(
27+
env!("CARGO_PKG_VERSION"),
28+
" (",
29+
env!("KUMA_BUILD_SHA"),
30+
" ",
31+
env!("KUMA_BUILD_DATE"),
32+
")"
33+
);
34+
2235
#[derive(Parser)]
23-
#[command(name = "kuma", version, about = "Your system is one file.")]
36+
#[command(name = "kuma", version = VERSION, about = "Your system is one file.")]
2437
struct Cli {
2538
/// Path to the kuma config file [default: ./kuma.toml, else
2639
/// ~/.config/kuma/kuma.toml when the current directory has none]

0 commit comments

Comments
 (0)