Show the build in a footer, linked to its release or commit

The version moves from the header to a "Powered by Jarl" footer. A build
of a release tag (HEAD tagged v<VERSION>, no tracked changes; the release
workflow sets RELEASE_TAG) shows and links the release; any other build
shows and links its commit, marked -modified with uncommitted changes.
jarld version, jarl --version, /status and /auth/whoami report the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-09 14:48:24 +00:00
1 parent b05711b13e
commit db9ebb14dd
14 files changed
+176 -14

No files matched your search

+4
View File
@@ -8,6 +8,7 @@ import (
"jarl/internal/model"
"jarl/internal/nscheck"
"jarl/internal/version"
)
// Version is the API path prefix.
@@ -92,6 +93,8 @@ type Whoami struct {
Permissions []string `json:"permissions"`
Admin bool `json:"admin"`
Version string `json:"version"`
// Build is what the server was built from (release or commit).
Build version.Build `json:"build"`
}
// LoginRequest logs in.
@@ -156,6 +159,7 @@ type Changeset struct {
// StatusResponse is GET /status.
type StatusResponse struct {
Version string `json:"version"`
Build version.Build `json:"build"`
Engine json.RawMessage `json:"engine"`
Time time.Time `json:"time"`
// ExpiringTokens are tokens that expire within TokenExpiryWarning or
+2 -1
View File
@@ -13,6 +13,7 @@ import (
"jarl/internal/engine"
"jarl/internal/model"
"jarl/internal/store"
"jarl/internal/version"
)
func toRequests(in []api.ChangeRequest) []engine.Request {
@@ -273,7 +274,7 @@ func (s *Server) status(w http.ResponseWriter, r *http.Request, p *auth.Principa
}
}
now := time.Now()
resp := api.StatusResponse{Version: s.cfg.Version, Engine: raw(st), Time: now, ExpiringTokens: s.expiringTokens(r.Context(), p, now)}
resp := api.StatusResponse{Version: s.cfg.Version, Build: version.Info(), Engine: raw(st), Time: now, ExpiringTokens: s.expiringTokens(r.Context(), p, now)}
if p.IsAdmin() {
resp.Backup = s.backupStatus(r.Context(), now)
}
+2 -1
View File
@@ -9,6 +9,7 @@ import (
"jarl/internal/model"
"jarl/internal/render"
"jarl/internal/rr"
"jarl/internal/version"
)
func (s *Server) presentZone(st *model.State, z *model.Zone, counts map[int64]int) api.Zone {
@@ -132,7 +133,7 @@ func presentToken(t *model.Token) api.Token {
}
func (s *Server) whoamiOf(p *auth.Principal, se *model.Session) api.Whoami {
out := api.Whoami{User: presentUser(p.User), Method: p.Method, Permissions: p.Summary(), Admin: p.IsAdmin(), Version: s.cfg.Version}
out := api.Whoami{User: presentUser(p.User), Method: p.Method, Permissions: p.Summary(), Admin: p.IsAdmin(), Version: s.cfg.Version, Build: version.Info()}
if p.Token != nil {
t := presentToken(p.Token)
out.Token = &t
+89 -2
View File
@@ -1,7 +1,94 @@
// Package version holds Jarl's version, set at build time with
//
// go build -ldflags "-X jarl/internal/version.Version=1.2.3"
// go build -ldflags "-X jarl/internal/version.Version=1.2.3 -X jarl/internal/version.Tag=v1.2.3"
//
// The Makefile sets Version from the VERSION file, and Tag only when the
// build is of that release: HEAD is the tag v<VERSION> and no tracked file
// is changed. Other builds are identified by their commit, which Go
// records from git by itself.
package version
// Version is the Jarl version.
import (
"runtime/debug"
"sync"
)
// Version is the Jarl version (the VERSION file).
var Version = "0.1.0-dev"
// Tag is the release tag the build is of ("" when it isn't a release).
var Tag = ""
// Commit is the git commit the build is of. It is read from the build
// information when not set at build time.
var Commit = ""
// Repo is Jarl's source repository.
const Repo = "https://gitea.adbraun.lol/adam/jarl"
// Build describes what a binary was built from.
type Build struct {
Version string `json:"version"`
Tag string `json:"tag,omitempty"`
Commit string `json:"commit,omitempty"`
Modified bool `json:"modified,omitempty"` // built with uncommitted changes
// Label is the short form to show: the tag of a release, otherwise
// the abbreviated commit (or the version when the commit is unknown).
Label string `json:"label"`
// URL is the release or commit in the repository.
URL string `json:"url"`
}
var info = sync.OnceValue(func() Build {
commit, modified := Commit, false
if bi, ok := debug.ReadBuildInfo(); ok {
for _, s := range bi.Settings {
switch s.Key {
case "vcs.revision":
if commit == "" {
commit = s.Value
}
case "vcs.modified":
modified = s.Value == "true"
}
}
}
return describe(Version, Tag, commit, modified)
})
func describe(version, tag, commit string, modified bool) Build {
b := Build{Version: version, Tag: tag, Commit: commit, Modified: modified}
switch {
case tag != "":
b.Modified = false // the Makefile only sets Tag for clean trees
b.Label, b.URL = tag, Repo+"/releases/tag/"+tag
case commit != "":
b.Label, b.URL = short(commit), Repo+"/commit/"+commit
if modified {
b.Label += "-modified"
}
default:
b.Label, b.URL = version, Repo
}
return b
}
// Info describes this build.
func Info() Build { return info() }
// String is the version for logs and 'version' commands: "0.2.0" for a
// release, otherwise the version and the commit ("0.2.0-dev (a1b2c3d4e5f6)").
func String() string {
b := Info()
if b.Tag != "" || b.Commit == "" {
return b.Version
}
return b.Version + " (" + b.Label + ")"
}
func short(commit string) string {
if len(commit) > 12 {
return commit[:12]
}
return commit
}
+22
View File
@@ -0,0 +1,22 @@
package version
import "testing"
func TestDescribe(t *testing.T) {
const c = "4d00be5e1f0a9b8c7d6e5f4a3b2c1d0e9f8a7b6c"
for _, tc := range []struct {
tag, commit string
modified bool
label, url string
}{
{"v0.1.0", c, true, "v0.1.0", Repo + "/releases/tag/v0.1.0"},
{"", c, false, "4d00be5e1f0a", Repo + "/commit/" + c},
{"", c, true, "4d00be5e1f0a-modified", Repo + "/commit/" + c},
{"", "", false, "0.1.0", Repo},
} {
b := describe("0.1.0", tc.tag, tc.commit, tc.modified)
if b.Label != tc.label || b.URL != tc.url {
t.Errorf("describe(%q, %q, %v) = %q %q, want %q %q", tc.tag, tc.commit, tc.modified, b.Label, b.URL, tc.label, tc.url)
}
}
}
+12 -3
View File
@@ -93,8 +93,7 @@ function layout() {
const header = h("header", { class: "top" },
h("a", { class: "brand", href: "#/", title: "Dashboard" },
logo(30),
wordmark({ rule: false, height: 19, cls: "brand-name" }),
h("span", { class: "brand-version" }, me.version)),
wordmark({ rule: false, height: 19, cls: "brand-name" })),
navEl,
h("div", { class: "spacer" }),
modeEl = h("span"),
@@ -102,7 +101,7 @@ function layout() {
h("a", { href: "#/account", class: "user", title: (me.permissions || []).join("\n") }, me.user.username),
me.admin ? h("a", { href: "#/users" }, "Users") : null,
h("button", { class: "small", onclick: logout }, "Log out"));
mount(app, header, barEl, mainEl);
mount(app, h("div", { class: "shell" }, header, barEl, mainEl, footer(me.build)));
modeHolder = modeEl;
renderNav();
renderBar();
@@ -110,6 +109,16 @@ function layout() {
let modeHolder;
// footer names the Jarl build: the release, or the commit for other
// builds, linked to it in the repository.
function footer(b) {
if (!b) return null;
const what = b.tag ? "release " + b.tag
: b.commit ? "commit " + b.commit + (b.modified ? ", with uncommitted changes" : "") : "version " + b.version;
return h("footer", { class: "site" }, "Powered by ",
h("a", { href: b.url, target: "_blank", rel: "noopener", title: "Jarl " + b.version + ", built from " + what }, "Jarl " + b.label));
}
function renderNav() {
mount(navEl, navItems.map(([key, href, label]) => h("a", { href, class: current.nav === key ? "active" : "" }, label)));
}
+7 -1
View File
@@ -97,7 +97,6 @@ header.top {
header.top .brand { display: inline-flex; align-items: center; gap: 8px; color: var(--text); text-decoration: none; }
header.top .brand .logo { display: block; transition: transform 0.15s; }
header.top .brand .brand-name { display: block; color: var(--text); }
header.top .brand .brand-version { color: var(--muted); font-size: 12px; align-self: flex-end; padding-bottom: 2px; }
header.top .brand:hover { text-decoration: none; }
header.top .brand:hover .brand-name { color: var(--accent); }
header.top .brand:hover .logo { transform: scale(1.06); }
@@ -403,3 +402,10 @@ button.chip:hover { background: var(--accent-soft); border-color: var(--accent);
.swatch.selected { box-shadow: 0 0 0 2px currentColor; }
.swatch:focus-visible { outline: 2px solid var(--text); outline-offset: 2px; }
tr.selected td { background: var(--accent-soft); }
/* the page footer with the Jarl build */
.shell { min-height: 100vh; display: flex; flex-direction: column; }
.shell > main { flex: 1; width: 100%; }
footer.site { padding: 12px 16px 16px; text-align: center; font-size: 12px; color: var(--muted); }
footer.site a { color: var(--muted); text-decoration: underline; text-decoration-color: var(--border); text-underline-offset: 2px; }
footer.site a:hover { color: var(--accent); text-decoration-color: currentColor; }