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
@@ -48,6 +48,10 @@ jobs:
run: make test run: make test
- name: Build - name: Build
env:
# Checked against VERSION above; don't depend on the checkout
# having the tag.
RELEASE_TAG: ${{ github.ref_name }}
run: | run: |
export DEB_MAINTAINER="$(git log -1 --format='%an <%ae>')" export DEB_MAINTAINER="$(git log -1 --format='%an <%ae>')"
make dist make dist
+8
View File
@@ -26,6 +26,14 @@ All notable changes to Jarl are documented here. The format follows
### Changed ### Changed
- The version moved from the header to a footer: "Powered by Jarl v0.1.0",
linking to the release on Gitea. A build that isn't of a release tag
shows its commit instead, linked to the commit and marked `-modified`
with uncommitted changes. Before, every build of a commit after a
release still showed the release's version. `jarld version`,
`jarl --version`, `GET /status` and `/auth/whoami` report the same, the
latter two as `build` (docs/development.md).
- The view in the zone list, and on the zone page, links to that view, - The view in the zone list, and on the zone page, links to that view,
and the views page highlights it. It used to be plain text, and the zone and the views page highlights it. It used to be plain text, and the zone
page linked to the views page as a whole. page linked to the views page as a whole.
+5 -1
View File
@@ -14,7 +14,11 @@
# (jarl-cli-<version>-<os>-<arch>[.exe]) # (jarl-cli-<version>-<os>-<arch>[.exe])
VERSION ?= $(shell cat VERSION 2>/dev/null || echo 0.0.0-dev) VERSION ?= $(shell cat VERSION 2>/dev/null || echo 0.0.0-dev)
LDFLAGS := -s -w -X jarl/internal/version.Version=$(VERSION) # RELEASE_TAG marks a build of a release (the web UI then shows and links
# the release rather than the commit): set when HEAD is tagged v$(VERSION)
# and no tracked file is changed.
RELEASE_TAG ?= $(shell t=v$(VERSION); git tag --points-at HEAD 2>/dev/null | grep -qx "$$t" && git diff --quiet HEAD 2>/dev/null && echo $$t)
LDFLAGS := -s -w -X jarl/internal/version.Version=$(VERSION) -X jarl/internal/version.Tag=$(RELEASE_TAG)
GOFLAGS := -trimpath GOFLAGS := -trimpath
export CGO_ENABLED := 0 export CGO_ENABLED := 0
+1 -1
View File
@@ -225,7 +225,7 @@ Exit codes: 0 ok, 1 error, 3 not authenticated or not allowed, 4 not found,
5 conflict, 6 invalid or rejected by validation, 7 server or BIND error.`, 5 conflict, 6 invalid or rejected by validation, 7 server or BIND error.`,
SilenceUsage: true, SilenceUsage: true,
SilenceErrors: true, SilenceErrors: true,
Version: version.Version, Version: version.String(),
PersistentPreRunE: func(cmd *cobra.Command, args []string) error { PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
return checkFanOut(cmd) return checkFanOut(cmd)
}, },
+1 -1
View File
@@ -383,7 +383,7 @@ func versionCmd() *cobra.Command {
Use: "version", Use: "version",
Short: "Print the version", Short: "Print the version",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
fmt.Println("jarld", version.Version) fmt.Println("jarld", version.String())
}, },
} }
} }
+3 -3
View File
@@ -50,7 +50,7 @@ func rootCmd() *cobra.Command {
Short: "Jarl: manage BIND (named) through a web UI, a REST API and a CLI", Short: "Jarl: manage BIND (named) through a web UI, a REST API and a CLI",
SilenceUsage: true, SilenceUsage: true,
SilenceErrors: true, SilenceErrors: true,
Version: version.Version, Version: version.String(),
} }
def := os.Getenv("JARL_CONFIG") def := os.Getenv("JARL_CONFIG")
if def == "" { if def == "" {
@@ -295,7 +295,7 @@ func serveCmd() *cobra.Command {
Listen: a.cfg.Listen, TLSCertFile: a.cfg.TLS.CertFile, TLSKeyFile: a.cfg.TLS.KeyFile, Listen: a.cfg.Listen, TLSCertFile: a.cfg.TLS.CertFile, TLSKeyFile: a.cfg.TLS.KeyFile,
TrustedProxies: a.cfg.Proxies(), SessionLifetime: time.Duration(a.cfg.Session.Lifetime), TrustedProxies: a.cfg.Proxies(), SessionLifetime: time.Duration(a.cfg.Session.Lifetime),
SessionIdle: time.Duration(a.cfg.Session.IdleTimeout), CookieSecure: a.cfg.Session.CookieSecure, SessionIdle: time.Duration(a.cfg.Session.IdleTimeout), CookieSecure: a.cfg.Session.CookieSecure,
Version: version.Version, Stats: collector, Socket: a.cfg.Socket, Version: version.String(), Stats: collector, Socket: a.cfg.Socket,
BackupDir: a.cfg.Backup.Dir, BackupWarnAfter: time.Duration(a.cfg.Backup.WarnAfter), BackupDir: a.cfg.Backup.Dir, BackupWarnAfter: time.Duration(a.cfg.Backup.WarnAfter),
HTTPReq: server.HTTPReq{ HTTPReq: server.HTTPReq{
Enabled: a.cfg.ACME.HTTPReq.Enabled, Enabled: a.cfg.ACME.HTTPReq.Enabled,
@@ -313,7 +313,7 @@ func serveCmd() *cobra.Command {
} }
}() }()
go auditRetention(ctx, a) go auditRetention(ctx, a)
a.log.Info("jarld starting", "version", version.Version, "database", a.cfg.Database, "bind_config", a.cfg.BIND.ConfigDir) a.log.Info("jarld starting", "version", version.String(), "database", a.cfg.Database, "bind_config", a.cfg.BIND.ConfigDir)
err = srv.Serve(ctx, func() { sdNotify("READY=1\nSTATUS=serving") }) err = srv.Serve(ctx, func() { sdNotify("READY=1\nSTATUS=serving") })
sdNotify("STOPPING=1") sdNotify("STOPPING=1")
a.log.Info("jarld stopped") a.log.Info("jarld stopped")
+16
View File
@@ -202,6 +202,22 @@ component `main` ([install.md](install.md#from-the-package-registry)). A
version with a hyphen (`1.0.0-rc.1`) becomes a pre-release; its packages version with a hyphen (`1.0.0-rc.1`) becomes a pre-release; its packages
get version `1.0.0~rc.1`, which apt sorts before `1.0.0`. get version `1.0.0~rc.1`, which apt sorts before `1.0.0`.
### Which version a build reports
The footer of the web UI, `jarld version` and `jarl --version` say what a
binary was built from, linking to it on Gitea:
* **A release**: `make` run on the commit tagged `v<VERSION>` with no
tracked file changed. The Makefile then sets `RELEASE_TAG`, and the
footer reads "Powered by Jarl v0.2.0", linking to the release. The
release workflow sets `RELEASE_TAG` itself.
* **Any other build**: the commit, which Go records from git: "Powered by
Jarl 1a2b3c4d5e6f", linking to the commit. With uncommitted changes it
is marked `-modified`. The version commands print `0.2.0 (1a2b3c4d5e6f)`.
* **No git** (a build from a source tarball): just `VERSION`.
`RELEASE_TAG=v0.2.0 make` forces the first case.
Pushing the same tag again (`git tag -f`, `git push -f`) re-runs it, and Pushing the same tag again (`git tag -f`, `git push -f`) re-runs it, and
the script replaces the attachments and packages, so a failed release can the script replaces the attachments and packages, so a failed release can
simply be retried. simply be retried.
+4
View File
@@ -8,6 +8,7 @@ import (
"jarl/internal/model" "jarl/internal/model"
"jarl/internal/nscheck" "jarl/internal/nscheck"
"jarl/internal/version"
) )
// Version is the API path prefix. // Version is the API path prefix.
@@ -92,6 +93,8 @@ type Whoami struct {
Permissions []string `json:"permissions"` Permissions []string `json:"permissions"`
Admin bool `json:"admin"` Admin bool `json:"admin"`
Version string `json:"version"` Version string `json:"version"`
// Build is what the server was built from (release or commit).
Build version.Build `json:"build"`
} }
// LoginRequest logs in. // LoginRequest logs in.
@@ -156,6 +159,7 @@ type Changeset struct {
// StatusResponse is GET /status. // StatusResponse is GET /status.
type StatusResponse struct { type StatusResponse struct {
Version string `json:"version"` Version string `json:"version"`
Build version.Build `json:"build"`
Engine json.RawMessage `json:"engine"` Engine json.RawMessage `json:"engine"`
Time time.Time `json:"time"` Time time.Time `json:"time"`
// ExpiringTokens are tokens that expire within TokenExpiryWarning or // ExpiringTokens are tokens that expire within TokenExpiryWarning or
+2 -1
View File
@@ -13,6 +13,7 @@ import (
"jarl/internal/engine" "jarl/internal/engine"
"jarl/internal/model" "jarl/internal/model"
"jarl/internal/store" "jarl/internal/store"
"jarl/internal/version"
) )
func toRequests(in []api.ChangeRequest) []engine.Request { 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() 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() { if p.IsAdmin() {
resp.Backup = s.backupStatus(r.Context(), now) resp.Backup = s.backupStatus(r.Context(), now)
} }
+2 -1
View File
@@ -9,6 +9,7 @@ import (
"jarl/internal/model" "jarl/internal/model"
"jarl/internal/render" "jarl/internal/render"
"jarl/internal/rr" "jarl/internal/rr"
"jarl/internal/version"
) )
func (s *Server) presentZone(st *model.State, z *model.Zone, counts map[int64]int) api.Zone { 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 { 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 { if p.Token != nil {
t := presentToken(p.Token) t := presentToken(p.Token)
out.Token = &t out.Token = &t
+89 -2
View File
@@ -1,7 +1,94 @@
// Package version holds Jarl's version, set at build time with // 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 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" 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" }, const header = h("header", { class: "top" },
h("a", { class: "brand", href: "#/", title: "Dashboard" }, h("a", { class: "brand", href: "#/", title: "Dashboard" },
logo(30), logo(30),
wordmark({ rule: false, height: 19, cls: "brand-name" }), wordmark({ rule: false, height: 19, cls: "brand-name" })),
h("span", { class: "brand-version" }, me.version)),
navEl, navEl,
h("div", { class: "spacer" }), h("div", { class: "spacer" }),
modeEl = h("span"), modeEl = h("span"),
@@ -102,7 +101,7 @@ function layout() {
h("a", { href: "#/account", class: "user", title: (me.permissions || []).join("\n") }, me.user.username), h("a", { href: "#/account", class: "user", title: (me.permissions || []).join("\n") }, me.user.username),
me.admin ? h("a", { href: "#/users" }, "Users") : null, me.admin ? h("a", { href: "#/users" }, "Users") : null,
h("button", { class: "small", onclick: logout }, "Log out")); 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; modeHolder = modeEl;
renderNav(); renderNav();
renderBar(); renderBar();
@@ -110,6 +109,16 @@ function layout() {
let modeHolder; 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() { function renderNav() {
mount(navEl, navItems.map(([key, href, label]) => h("a", { href, class: current.nav === key ? "active" : "" }, label))); 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 { 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 .logo { display: block; transition: transform 0.15s; }
header.top .brand .brand-name { display: block; color: var(--text); } 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 { text-decoration: none; }
header.top .brand:hover .brand-name { color: var(--accent); } header.top .brand:hover .brand-name { color: var(--accent); }
header.top .brand:hover .logo { transform: scale(1.06); } 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.selected { box-shadow: 0 0 0 2px currentColor; }
.swatch:focus-visible { outline: 2px solid var(--text); outline-offset: 2px; } .swatch:focus-visible { outline: 2px solid var(--text); outline-offset: 2px; }
tr.selected td { background: var(--accent-soft); } 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; }