Show the build version in the UI and --version
CI passes the release image tag in as DOCKET_VERSION, and previews pass
pr-<number>-<sha>. Builds without it are named by build.rs after the
date and the jj change they came from, with a -dev suffix, so a local
build is never mistaken for a release.

The version sits in the bottom-right corner, in a strip the app grid now
leaves free below the panes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
change vqvozlkntwmqnuoytspnrwuvwxvputsx
commit 1b01e1bed7f24fcf2282c3a40356f7a0906ccb6f
author Alpha Chen <alpha@kejadlen.dev>
date
parent nzsutwkz
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 9b8853f..8c53c19 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -78,6 +78,7 @@ jobs:
           push: true
           tags: ${{ steps.meta.outputs.tags }}
           labels: ${{ steps.meta.outputs.labels }}
+          build-args: DOCKET_VERSION=${{ steps.meta.outputs.version }}
           cache-from: type=gha
           cache-to: type=gha,mode=max
       - env:
@@ -120,6 +121,8 @@ jobs:
 
       # A build of its own from fly.toml, with the `dev` feature the
       # release image leaves out. It still waits on the release, so the
-      # demo only runs commits that shipped.
+      # demo only runs commits that shipped, and carries its version.
       - name: Deploy a demo build
-        run: flyctl deploy --app "$FLY_APP" --config fly.toml --remote-only
+        env:
+          NEEDS_BUILD_OUTPUTS_VERSION: ${{ needs.build.outputs.version }}
+        run: flyctl deploy --app "$FLY_APP" --config fly.toml --remote-only --build-arg "DOCKET_VERSION=$NEEDS_BUILD_OUTPUTS_VERSION"
diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml
index 12284e4..c72ec3c 100644
--- a/.github/workflows/preview.yml
+++ b/.github/workflows/preview.yml
@@ -31,6 +31,14 @@ jobs:
 
       - uses: superfly/flyctl-actions/setup-flyctl@ed8efb33836e8b2096c7fd3ba1c8afe303ebbff1 # 1.6
 
+      # Names the build after the PR and the commit it shows.
+      - name: Version
+        id: version
+        env:
+          PR: ${{ github.event.number }}
+          SHA: ${{ github.event.pull_request.head.sha }}
+        run: echo "version=pr-${PR}-${SHA::7}" >> "$GITHUB_OUTPUT"
+
       # Creates the app on open, redeploys it on each push, and destroys
       # it when the PR closes.
       - name: Deploy preview
@@ -39,3 +47,4 @@ jobs:
         with:
           config: fly.toml
           memory: 256
+          build_args: DOCKET_VERSION=${{ steps.version.outputs.version }}
diff --git a/Dockerfile b/Dockerfile
index 67588cf..982fd80 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -22,6 +22,8 @@ COPY . .
 # Cargo features to build with. Empty for the release image; the Fly demos
 # set `dev` (see fly.toml).
 ARG FEATURES=""
+# Shown in the UI and by --version; build.rs names local builds without it.
+ARG DOCKET_VERSION=""
 # Copy the binary out of the cache mount so it survives into the runtime
 # stage.
 RUN --mount=type=cache,target=/usr/local/cargo/registry \
diff --git a/assets/docket.css b/assets/docket.css
index 4e2ef22..88a25a6 100644
--- a/assets/docket.css
+++ b/assets/docket.css
@@ -12,7 +12,7 @@ button { font: inherit; color: inherit; }
 
 /* Three panes on the fill: bare mailboxes, the list on a card, the thread
    as a stack of cards. */
-.app { height: 100vh; display: grid; grid-template-columns: 200px 420px minmax(0, 1fr); gap: var(--gl-space-2xs); padding: var(--gl-space-2xs); background: var(--gl-color-surface-fill); }
+.app { height: 100vh; display: grid; grid-template-columns: 200px 420px minmax(0, 1fr); gap: var(--gl-space-2xs); padding: var(--gl-space-2xs) var(--gl-space-2xs) var(--gl-space-s); background: var(--gl-color-surface-fill); }
 
 /* Gloss Input: a field on a rule. */
 .field { box-sizing: border-box; width: 100%; border: none; border-bottom: var(--gl-border-w) solid var(--gl-color-border-default); border-radius: 0; background: transparent; padding: 8px 2px; font: var(--gl-step--1) var(--gl-font-text); color: var(--gl-color-text-primary); outline: none; }
@@ -33,6 +33,8 @@ button { font: inherit; color: inherit; }
 /* ── mailboxes ── */
 .side { padding: var(--gl-space-m) var(--gl-space-s) var(--gl-space-2xs); display: flex; flex-direction: column; gap: var(--gl-space-m); min-height: 0; overflow-y: auto; }
 .brand-row { display: flex; justify-content: space-between; align-items: center; }
+/* Centered in the strip .app leaves free at the bottom right. */
+.version { position: fixed; right: var(--gl-space-2xs); bottom: calc((var(--gl-space-s) - 1em) / 2); text-transform: none; letter-spacing: 0; color: var(--gl-color-text-tertiary); }
 .search-btn { all: unset; display: flex; cursor: pointer; color: var(--gl-color-text-tertiary); transition: color var(--gl-dur-fast) var(--gl-ease); }
 .search-btn:hover { color: var(--gl-color-text-primary); }
 .search-btn:focus-visible { outline: 2px solid var(--gl-color-accent); outline-offset: 2px; border-radius: 2px; }
diff --git a/build.rs b/build.rs
new file mode 100644
index 0000000..654d643
--- /dev/null
+++ b/build.rs
@@ -0,0 +1,34 @@
+use std::process::Command;
+
+// CI passes the release's image tag as DOCKET_VERSION; anything else is a
+// local build, named for the day and the jj change it came from.
+fn main() {
+    println!("cargo:rerun-if-env-changed=DOCKET_VERSION");
+    let version = std::env::var("DOCKET_VERSION")
+        .ok()
+        .filter(|v| !v.is_empty());
+    let version = version.unwrap_or_else(|| {
+        // Every jj operation replaces the op head, so a new change renames
+        // the build.
+        println!("cargo:rerun-if-changed=.jj/repo/op_heads/heads");
+        let date = cmd("date", &["-u", "+%Y-%m-%d"]);
+        let change = cmd(
+            "jj",
+            &["log", "-r", "@", "--no-graph", "-T", "change_id.short()"],
+        );
+        format!("{date}+{change}-dev")
+    });
+    println!("cargo:rustc-env=DOCKET_VERSION={version}");
+}
+
+fn cmd(program: &str, args: &[&str]) -> String {
+    Command::new(program)
+        .args(args)
+        .output()
+        .ok()
+        .filter(|o| o.status.success())
+        .and_then(|o| String::from_utf8(o.stdout).ok())
+        .map(|s| s.trim().to_owned())
+        .filter(|s| !s.is_empty())
+        .unwrap_or_else(|| "unknown".into())
+}
diff --git a/src/bin/docket/config.rs b/src/bin/docket/config.rs
index 4674174..3099c4c 100644
--- a/src/bin/docket/config.rs
+++ b/src/bin/docket/config.rs
@@ -38,7 +38,7 @@ impl Config {
 
 /// Settings come from the KDL file; the CLI only says where to find it.
 #[derive(Debug, clap::Parser)]
-#[command(version, about = "Shared household email triage over JMAP")]
+#[command(version = docket::VERSION, about = "Shared household email triage over JMAP")]
 pub struct Args {
     /// KDL file to read settings from.
     #[arg(long, env = "DOCKET_CONFIG", default_value = "docket.kdl")]
diff --git a/src/lib.rs b/src/lib.rs
index 1868bc8..13912ac 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -10,3 +10,6 @@ pub mod store;
 pub mod views;
 
 pub use error::Error;
+
+/// Which build this is: the release's image tag, or a local `-dev` build.
+pub const VERSION: &str = env!("DOCKET_VERSION");
diff --git a/src/views.rs b/src/views.rs
index 877a9fe..ab5db15 100644
--- a/src/views.rs
+++ b/src/views.rs
@@ -133,6 +133,7 @@ pub fn page(p: &Page<'_>) -> Markup {
                     (list(p))
                     (reader(p))
                 }
+                span.type-label.version { (crate::VERSION) }
                 @if let Some(flash) = &p.flash {
                     (toast(flash, p.here))
                 }
diff --git a/tests/api.rs b/tests/api.rs
index 0a90e38..64ec318 100644
--- a/tests/api.rs
+++ b/tests/api.rs
@@ -138,6 +138,12 @@ async fn for_me_lists_messages_grouped_by_state_and_thread() {
     // Rows carry sender, age and snippet; values live in the thread.
     assert!(body.contains(r#"<span class="from">Northwind Roofing</span>"#));
     assert!(!body.contains("FOLDERS"));
+    // The page names the build.
+    let version = format!(
+        r#"<span class="type-label version">{}</span>"#,
+        docket::VERSION
+    );
+    assert!(body.contains(&version));
 }
 
 #[tokio::test]