diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index adc04de..394c15e 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -48,6 +48,10 @@ jobs: run: make test - name: Build + env: + # Checked against VERSION above; don't depend on the checkout + # having the tag. + RELEASE_TAG: ${{ github.ref_name }} run: | export DEB_MAINTAINER="$(git log -1 --format='%an <%ae>')" make dist diff --git a/CHANGELOG.md b/CHANGELOG.md index 12007ad..0c5b1e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,14 @@ All notable changes to Jarl are documented here. The format follows ### 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, and the views page highlights it. It used to be plain text, and the zone page linked to the views page as a whole. diff --git a/Makefile b/Makefile index c2a5d25..51d345b 100644 --- a/Makefile +++ b/Makefile @@ -14,7 +14,11 @@ # (jarl-cli---[.exe]) 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 export CGO_ENABLED := 0 diff --git a/cmd/jarl/main.go b/cmd/jarl/main.go index 2d106b7..286f0f7 100644 --- a/cmd/jarl/main.go +++ b/cmd/jarl/main.go @@ -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.`, SilenceUsage: true, SilenceErrors: true, - Version: version.Version, + Version: version.String(), PersistentPreRunE: func(cmd *cobra.Command, args []string) error { return checkFanOut(cmd) }, diff --git a/cmd/jarld/admin.go b/cmd/jarld/admin.go index 2c4d7df..65628aa 100644 --- a/cmd/jarld/admin.go +++ b/cmd/jarld/admin.go @@ -383,7 +383,7 @@ func versionCmd() *cobra.Command { Use: "version", Short: "Print the version", Run: func(cmd *cobra.Command, args []string) { - fmt.Println("jarld", version.Version) + fmt.Println("jarld", version.String()) }, } } diff --git a/cmd/jarld/main.go b/cmd/jarld/main.go index dbba407..06be990 100644 --- a/cmd/jarld/main.go +++ b/cmd/jarld/main.go @@ -50,7 +50,7 @@ func rootCmd() *cobra.Command { Short: "Jarl: manage BIND (named) through a web UI, a REST API and a CLI", SilenceUsage: true, SilenceErrors: true, - Version: version.Version, + Version: version.String(), } def := os.Getenv("JARL_CONFIG") if def == "" { @@ -295,7 +295,7 @@ func serveCmd() *cobra.Command { 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), 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), HTTPReq: server.HTTPReq{ Enabled: a.cfg.ACME.HTTPReq.Enabled, @@ -313,7 +313,7 @@ func serveCmd() *cobra.Command { } }() 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") }) sdNotify("STOPPING=1") a.log.Info("jarld stopped") diff --git a/docs/development.md b/docs/development.md index cd2b2c1..c57cb81 100644 --- a/docs/development.md +++ b/docs/development.md @@ -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 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` 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 the script replaces the attachments and packages, so a failed release can simply be retried. diff --git a/internal/api/types.go b/internal/api/types.go index 143e376..c48cc40 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -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 diff --git a/internal/server/h_changes.go b/internal/server/h_changes.go index 1b85f28..78b49ce 100644 --- a/internal/server/h_changes.go +++ b/internal/server/h_changes.go @@ -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) } diff --git a/internal/server/present.go b/internal/server/present.go index 92eccec..c602c5b 100644 --- a/internal/server/present.go +++ b/internal/server/present.go @@ -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 diff --git a/internal/version/version.go b/internal/version/version.go index 7675729..8208b4b 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -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 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 +} diff --git a/internal/version/version_test.go b/internal/version/version_test.go new file mode 100644 index 0000000..14a9422 --- /dev/null +++ b/internal/version/version_test.go @@ -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) + } + } +} diff --git a/internal/web/static/assets/app.js b/internal/web/static/assets/app.js index 864ee88..cf682ea 100644 --- a/internal/web/static/assets/app.js +++ b/internal/web/static/assets/app.js @@ -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))); } diff --git a/internal/web/static/assets/style.css b/internal/web/static/assets/style.css index c4f77dc..248cc50 100644 --- a/internal/web/static/assets/style.css +++ b/internal/web/static/assets/style.css @@ -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; }