OXYGENHUB

Versions on the hub: About page, /api/v1/version, footer, oh --version e9bfea8e26db

The hub reports its version and build (oxy describe at deploy, which make
deploy now passes as OXY_BUILD; a Flow build's heads; or "local build")
with the oxy-core and flow-core versions it is built on: on a new /about
page (with each runner's reported version for administrators), at
/api/v1/version, in /health, and in the footer, which links to /about.
oh --version names its build the same way. The oxygen and oxygenflow pins
move to their build identity.

Asked for by Andy as well: the repository bar is as wide as its contents
need and wraps only when the window is too narrow, and the page always
keeps room for the scroll bar so nothing shifts when it appears.

closes #55
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-29 17:47 UTC
Signature
✔ signed by Andy McCall
Hash
e9bfea8e26dbe8e3ff057ed872f624628459fafb29eb268032c53bff5c7a83fc
Depends on
18e381ea88c2 19bb31ff1f5c 380996db84e5 38833ed78707 42a0ba4bac4e 42fa95540468 5c6e92fd8d71 756fe44a0f4d 8d2caf122517 8f3d2ccfe6fc 90326c9601f9 b64e2537d702 b6b7e4cf6b24 d23464c0ac60
Depended on by
aeed63c00ad7 166f6e9bf4f1 3a01077da29a c9aacc0e7dc1 b210727578e8 745d225de344 967ef74f6fd9 60f11b71a740 df71c7892d27 3d3d29fbcb00
In bookmarks
api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
In tags
v0.1.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
Edits
24 atoms, 3 tree operations, 12 files

Browse the tree at this change

added crates/hub/build.rs +20 −0
1 +
//! The build's identity, for the version it reports: `OXY_BUILD` when a release or
2 +
//! an install sets it (from `oxy describe`), else the heads a Flow job is
3 +
//! building, else "local build".
4 +
fn main() {
5 +
    println!("cargo:rerun-if-env-changed=OXY_BUILD");
6 +
    println!("cargo:rerun-if-env-changed=FLOW_HEADS");
7 +
    let build = std::env::var("OXY_BUILD")
8 +
        .ok()
9 +
        .map(|b| b.trim().to_string())
10 +
        .filter(|b| !b.is_empty())
11 +
        .or_else(|| {
12 +
            std::env::var("FLOW_HEADS")
13 +
                .ok()
14 +
                .and_then(|h| h.split('+').next().map(|x| format!("flow #{}", &x[..12.min(x.len())])))
15 +
                .filter(|b| b.len() > "flow #".len())
16 +
        })
17 +
        .unwrap_or_else(|| "local build".into());
18 +
    println!("cargo:rustc-env=OXY_BUILD_ID={build}");
19 +
    println!("cargo:rustc-env=OXY_VERSION_LONG={} ({build})", std::env::var("CARGO_PKG_VERSION").unwrap());
20 +
}
added crates/oh/build.rs +20 −0
1 +
//! The build's identity, for the version it reports: `OXY_BUILD` when a release or
2 +
//! an install sets it (from `oxy describe`), else the heads a Flow job is
3 +
//! building, else "local build".
4 +
fn main() {
5 +
    println!("cargo:rerun-if-env-changed=OXY_BUILD");
6 +
    println!("cargo:rerun-if-env-changed=FLOW_HEADS");
7 +
    let build = std::env::var("OXY_BUILD")
8 +
        .ok()
9 +
        .map(|b| b.trim().to_string())
10 +
        .filter(|b| !b.is_empty())
11 +
        .or_else(|| {
12 +
            std::env::var("FLOW_HEADS")
13 +
                .ok()
14 +
                .and_then(|h| h.split('+').next().map(|x| format!("flow #{}", &x[..12.min(x.len())])))
15 +
                .filter(|b| b.len() > "flow #".len())
16 +
        })
17 +
        .unwrap_or_else(|| "local build".into());
18 +
    println!("cargo:rustc-env=OXY_BUILD_ID={build}");
19 +
    println!("cargo:rustc-env=OXY_VERSION_LONG={} ({build})", std::env::var("CARGO_PKG_VERSION").unwrap());
20 +
}
added templates/about.html +22 −0
1 +
{% extends "base.html" %}
2 +
{% block title %}About{% endblock %}
3 +
{% block content %}
4 +
<div class="head"><h1>About <small>{{ shell.site }}</small></h1></div>
5 +
<section class="panel">
6 +
  <h2>This hub</h2>
7 +
  <dl class="facts">
8 +
    <dt>OxygenHub</dt><dd><strong>{{ shell.version }}</strong> <span class="mute">({{ shell.build }})</span></dd>
9 +
    <dt>Built on</dt><dd>oxy-core {{ oxy_core }} · flow-core {{ flow_core }}</dd>
10 +
  </dl>
11 +
  <p class="small mute">The build names the state it was built from (<code>oxy describe</code>), the heads of a Flow build, or says "local build". The same is at <a href="/api/v1/version">/api/v1/version</a>; <code>oxy --version</code>, <code>oh --version</code> and <code>flow --version</code> report theirs, and the VS Code extensions show theirs in the Extensions view.</p>
12 +
</section>
13 +
{% if !runners.is_empty() %}
14 +
<section class="panel">
15 +
  <h2>Runners</h2>
16 +
  <div class="table"><table>
17 +
  <tr><th>Runner</th><th>Status</th><th>flow</th><th>Machine</th></tr>
18 +
  {% for r in runners %}<tr><td><a href="/admin/runners/{{ r.id }}">{{ r.name }}</a></td><td><span class="runner-status st-{{ r.status() }}">{{ r.status() }}</span></td><td>{% if r.version.is_empty() %}<span class="mute">not reported</span>{% else %}{{ r.version }}{% endif %}</td><td class="small">{{ r.arch }} {{ r.os }}</td></tr>{% endfor %}
19 +
  </table></div>
20 +
</section>
21 +
{% endif %}
22 +
{% endblock %}
modified Makefile +1 −1
…
23 unchanged lines
24 24
# Developer edition on Earth (docs/deploy.md).
25 25
deploy:
26 26
	rsync -a --delete --exclude target --exclude node_modules --exclude .oxy/objects/loose ../oxygen ../oxygenflow ../oxygenhub claude@earth:src/
27 -
	ssh claude@earth 'source ~/.cargo/env && cd src/oxygenhub && cargo build --release && sudo install -m 0755 target/release/oxygenhub /usr/local/bin/oxygenhub && sudo systemctl restart oxygenhub && systemctl is-active oxygenhub'
27 +
	B="$$(oxy --plain describe 2>/dev/null)"; ssh claude@earth "source ~/.cargo/env && cd src/oxygenhub && OXY_BUILD='$$B' cargo build --release && sudo install -m 0755 target/release/oxygenhub /usr/local/bin/oxygenhub && sudo systemctl restart oxygenhub && systemctl is-active oxygenhub"
28 28
29 29
# Move the pinned oxygenflow (deps/oxygenflow) to its main on Earth, with the
30 30
# oxygen it pins; the next commit records the new pin. Push oxygen, then
…
4 unchanged lines
modified crates/hub/src/api/mod.rs +7 −0
…
17 unchanged lines
18 18
mod projects;
19 19
20 20
/// Any other path under `/api/v1`: a problem document, not the site's page.
21 +
/// `GET /api/v1/version`: the hub's version and build, and the Oxygen and
22 +
/// OxygenFlow library versions it is built on.
23 +
async fn version() -> Json<serde_json::Value> {
24 +
    Json(crate::web::home::versions())
25 +
}
26 +
21 27
async fn no_route() -> ApiError {
22 28
    ApiError(StatusCode::NOT_FOUND, "no such API route".into())
23 29
}
…
14 unchanged lines
38 44
        .route("/projects/{owner}/{number}/cards", post(projects::add_card))
39 45
        .route("/projects/{owner}/{number}/cards/{id}", axum::routing::patch(projects::move_card).delete(projects::remove_card))
40 46
        .route("/user", get(me))
47 +
        .route("/version", get(version))
41 48
        .route("/notifications", get(notifications))
42 49
        .route("/notifications/read", post(notifications_read_all))
43 50
        .route("/notifications/{id}/read", post(notification_read))
…
1626 unchanged lines
modified crates/hub/src/web/home.rs +29 −0
…
226 unchanged lines
227 227
    let body = serde_json::json!({
228 228
        "ok": true,
229 229
        "version": env!("CARGO_PKG_VERSION"),
230 +
        "build": env!("OXY_BUILD_ID"),
230 231
        "uptime_s": app.started.elapsed().as_secs(),
231 232
    });
232 233
    axum::Json(body).into_response()
…
24 unchanged lines
257 258
            .into_response(),
258 259
        Err(_) => (axum::http::StatusCode::NOT_FOUND, "no certificate").into_response(),
259 260
    }
261 +
}
262 +
263 +
/// What this hub is built from (hub spec §15): its version and build, and
264 +
/// the versions of the Oxygen and OxygenFlow libraries it links.
265 +
pub fn versions() -> serde_json::Value {
266 +
    serde_json::json!({
267 +
        "oxygenhub": { "version": env!("CARGO_PKG_VERSION"), "build": env!("OXY_BUILD_ID") },
268 +
        "oxy_core": oxy_core::VERSION,
269 +
        "flow_core": flow_core::VERSION,
270 +
    })
271 +
}
272 +
273 +
#[derive(askama::Template)]
274 +
#[template(path = "about.html")]
275 +
pub struct AboutPage {
276 +
    shell: Shell,
277 +
    oxy_core: &'static str,
278 +
    flow_core: &'static str,
279 +
    /// Runners and the versions they report, for administrators.
280 +
    runners: Vec<crate::db::runners::Runner>,
281 +
}
282 +
283 +
/// `GET /about`: the hub's version and what it is built on; runners too, for
284 +
/// administrators.
285 +
pub async fn about(State(app): State<Shared>, viewer: Viewer) -> Result<Page<AboutPage>, Error> {
286 +
    let shell = Shell::new(&app, "/about", &viewer);
287 +
    let runners = if viewer.is_admin() { crate::db::runners::list(&app.db).await.unwrap_or_default() } else { vec![] };
288 +
    Ok(Page(AboutPage { shell, oxy_core: oxy_core::VERSION, flow_core: flow_core::VERSION, runners }))
260 289
}
modified crates/hub/src/web/mod.rs +6 −1
…
11 unchanged lines
12 12
mod forks;
13 13
pub mod help;
14 14
pub mod history;
15 -
mod home;
15 +
pub mod home;
16 16
mod inbox;
17 17
pub mod issues;
18 18
mod orgs;
…
31 unchanged lines
50 50
        .route("/explore", get(home::explore))
51 51
        .route("/avatar/{name}", get(avatar::avatar))
52 52
        .route("/health", get(home::health))
53 +
        .route("/about", get(home::about))
53 54
        .route("/.well-known/oxyhub-key", get(home::hub_key))
54 55
        .route("/.well-known/oxyhub-ca.pem", get(home::hub_ca))
55 56
        .route("/style.css", get(statics::style))
…
238 unchanged lines
294 295
    pub base_url: String,
295 296
    pub path: String,
296 297
    pub version: &'static str,
298 +
    /// What was built: `oxy describe` at deploy, a Flow build's heads, or "local build".
299 +
    pub build: &'static str,
297 300
    /// The signed-in username, if any.
298 301
    pub user: Option<String>,
299 302
    pub is_admin: bool,
…
8 unchanged lines
308 311
            base_url: app.config.base_url().to_string(),
309 312
            path: path.to_string(),
310 313
            version: env!("CARGO_PKG_VERSION"),
314 +
            build: env!("OXY_BUILD_ID"),
311 315
            user: viewer.account().map(|a| a.username.clone()),
312 316
            is_admin: viewer.is_admin(),
313 317
            csrf: viewer.csrf().to_string(),
…
192 unchanged lines
506 510
            base_url: String::new(),
507 511
            path: String::new(),
508 512
            version: "",
513 +
            build: "",
509 514
            user: None,
510 515
            is_admin: false,
511 516
            csrf: String::new(),
…
91 unchanged lines
modified crates/hub/tests/api.rs +18 −0
…
1112 unchanged lines
1113 1113
    let chain = &two[two.find("class=\"chain\"").expect("a chain on issue 2")..];
1114 1114
    assert!(chain.contains("proposal #3"), "{chain}");
1115 1115
}
1116 +
1117 +
/// What the hub is: its version and build, and the libraries it is built on,
1118 +
/// on /about, in the API and in /health (hub spec §15).
1119 +
#[tokio::test(flavor = "multi_thread")]
1120 +
async fn the_hub_says_what_version_it_is() {
1121 +
    let h = hub().await;
1122 +
    let app = &h.app;
1123 +
    let (st, v) = api(app, "GET", "/api/v1/version", None, None).await;
1124 +
    assert_eq!(st, StatusCode::OK);
1125 +
    assert_eq!(v["oxygenhub"]["version"], env!("CARGO_PKG_VERSION"));
1126 +
    assert!(v["oxygenhub"]["build"].as_str().is_some_and(|b| !b.is_empty()), "{v}");
1127 +
    assert!(v["oxy_core"].as_str().is_some() && v["flow_core"].as_str().is_some());
1128 +
    let health = call(app, "GET", "/health", None, None, None, None).await.body;
1129 +
    assert!(health.contains("\"build\""), "{health}");
1130 +
    let about = call(app, "GET", "/about", None, None, None, None).await.body;
1131 +
    assert!(about.contains(&format!("<strong>{}</strong>", env!("CARGO_PKG_VERSION"))) && about.contains("oxy-core"), "{about}");
1132 +
    assert!(about.contains("href=\"/about\""), "the footer links here");
1133 +
}
modified crates/oh/src/main.rs +1 −1
…
13 unchanged lines
14 14
use target::{Choice, Config, Host, Target};
15 15
16 16
#[derive(Parser)]
17 -
#[command(name = "oh", version, about = "OxygenHub from the command line: issues, proposals, Flow runs and releases")]
17 +
#[command(name = "oh", version = env!("OXY_VERSION_LONG"), about = "OxygenHub from the command line: issues, proposals, Flow runs and releases")]
18 18
struct Cli {
19 19
    /// The repository, when not inside an Oxygen checkout
20 20
    #[arg(long, short = 'R', global = true, value_name = "OWNER/NAME")]
…
697 unchanged lines
replaced deps/oxygenflow +0 −0

No line changes.

modified templates/base.html +1 −1
…
32 unchanged lines
33 33
  {% if shell.user.is_some() %}<a href="/notifications">Notifications</a><a href="/settings">Settings</a>{% endif %}
34 34
  <span class="grow"></span>
35 35
  {% if shell.is_admin %}<a class="admin" href="/admin">Admin</a>{% endif %}
36 -
  <a class="dim" href="/{{ shell.version }}" title="OxygenHub {{ shell.version }}" tabindex="-1">v{{ shell.version }}</a>
36 +
  <a class="dim" href="/about" title="OxygenHub {{ shell.version }} ({{ shell.build }})">v{{ shell.version }}</a>
37 37
</nav>
38 38
{% block tabs %}{% endblock %}
39 39
<main id="main" tabindex="-1"{% block mainclass %}{% endblock %}>
…
22 unchanged lines
modified templates/style.css +7 −2
…
30 unchanged lines
31 31
}
32 32
33 33
* { box-sizing: border-box; }
34 -
html { background: var(--bg); }
34 +
/* Room for the scroll bar always, so nothing shifts when it comes and goes. */
35 +
html { background: var(--bg); scrollbar-gutter: stable; }
35 36
body { margin: 0; min-height: 100vh; display: flex; flex-direction: column; background: var(--bg); color: var(--ink); font: 15px/1.45 "Barlow", "IBM Plex Sans", system-ui, sans-serif; }
36 37
a { color: var(--acc); text-decoration: none; }
37 38
a:hover { text-decoration: underline; }
…
39 unchanged lines
77 78
.band .grow { flex: 1; }
78 79
.band a.dim { opacity: .6; border: 0; }
79 80
.tabs { background: var(--panel); border-bottom: 1px solid var(--line); }
80 -
.tabs-in { max-width: 1180px; margin: 0 auto; padding: 0 22px; display: flex; align-items: center; gap: 2px; flex-wrap: wrap; }
81 +
/* The repository bar is as wide as its contents need: at least the page
82 +
   column (so it lines up with it), wider for a long name, and it wraps only
83 +
   when the window itself is too narrow. */
84 +
.tabs-in { width: fit-content; min-width: min(1180px, 100%); max-width: 100%; box-sizing: border-box; margin: 0 auto; padding: 0 22px; display: flex; align-items: center; gap: 2px; flex-wrap: wrap; }
85 +
.tabs .crumb { white-space: nowrap; }
81 86
.tabs .crumb { margin-right: 18px; padding: 10px 0; font-size: 16px; }
82 87
.tabs a:not(.crumb a) { padding: 10px 12px; color: var(--ink); font-size: 13px; }
83 88
.tabs a.on { border-bottom: 2px solid var(--acc); font-weight: 600; }
…
513 unchanged lines