From 653d065e8fd9c7f32568da255aa6d89677902bb5 Mon Sep 17 00:00:00 2001 From: O M Date: Tue, 27 Oct 2026 10:25:11 -0400 Subject: [PATCH] Show the running version and git commit in the console. Co-authored-by: Cursor --- .github/workflows/publish-image.yml | 16 +++ .github/workflows/release.yml | 16 +++ .goreleaser.yaml | 5 + Dockerfile | 12 +- README.md | 11 +- api/README.md | 3 +- internal/api/console_auth.go | 2 + internal/api/server.go | 7 ++ internal/api/server_test.go | 35 ++++++ internal/api/web/app.js | 83 +++++++++++++ internal/api/web/index.html | 27 +++++ internal/api/web/styles.css | 74 ++++++++++++ internal/cmd/root.go | 1 + internal/cmd/version.go | 24 ++++ internal/cmd/version_test.go | 20 +++ internal/version/version.go | 181 ++++++++++++++++++++++++++++ internal/version/version_test.go | 117 ++++++++++++++++++ 17 files changed, 629 insertions(+), 5 deletions(-) create mode 100644 internal/cmd/version.go create mode 100644 internal/cmd/version_test.go create mode 100644 internal/version/version.go create mode 100644 internal/version/version_test.go diff --git a/.github/workflows/publish-image.yml b/.github/workflows/publish-image.yml index f69f239..e778e3b 100644 --- a/.github/workflows/publish-image.yml +++ b/.github/workflows/publish-image.yml @@ -53,6 +53,18 @@ jobs: - name: Set up Buildx uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + - name: Resolve image version + id: buildmeta + run: | + version="${VERSION_REF#v}" + { + echo "version=${version}" + echo "commit=$(git rev-parse HEAD)" + echo "date=$(git log -1 --format=%cI)" + } >> "$GITHUB_OUTPUT" + env: + VERSION_REF: ${{ inputs.tag }} + - name: Build and push image id: push uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 @@ -61,6 +73,10 @@ jobs: push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + build-args: | + VERSION=${{ steps.buildmeta.outputs.version }} + COMMIT=${{ steps.buildmeta.outputs.commit }} + DATE=${{ steps.buildmeta.outputs.date }} - name: Sign image env: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 42ccc79..b4d416c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -63,6 +63,18 @@ jobs: - name: Set up Buildx uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + - name: Resolve image version + id: buildmeta + run: | + version="${VERSION_REF#v}" + { + echo "version=${version}" + echo "commit=$(git rev-parse HEAD)" + echo "date=$(git log -1 --format=%cI)" + } >> "$GITHUB_OUTPUT" + env: + VERSION_REF: ${{ github.ref_name }} + - name: Build and push image id: push uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 @@ -71,6 +83,10 @@ jobs: push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + build-args: | + VERSION=${{ steps.buildmeta.outputs.version }} + COMMIT=${{ steps.buildmeta.outputs.commit }} + DATE=${{ steps.buildmeta.outputs.date }} - name: Sign image env: diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 49a6770..12f471d 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -10,6 +10,11 @@ builds: binary: om env: - CGO_ENABLED=0 + ldflags: + - -s -w + - -X github.com/OpenSourceOM/core/internal/version.version={{ .Version }} + - -X github.com/OpenSourceOM/core/internal/version.commit={{ .FullCommit }} + - -X github.com/OpenSourceOM/core/internal/version.date={{ .CommitDate }} goos: - linux - darwin diff --git a/Dockerfile b/Dockerfile index 0b08cb6..f60bab5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,11 +2,21 @@ # SPDX-License-Identifier: Apache-2.0 FROM golang:1.27.3-alpine@sha256:b8c9b82affbbb82a020bbcf998dad2ba9516944ac7e883524642a481a9505e9c AS builder +RUN apk add --no-cache git WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . -RUN CGO_ENABLED=0 go build -o /om ./cmd/om +ARG VERSION= +ARG COMMIT= +ARG DATE= +# Empty VERSION, COMMIT, and DATE fall through to the VCS metadata Go stamps. +# safe.directory lets that work when the build context is owned by another user. +# Release workflows pass all three. +RUN git config --global --add safe.directory /src \ + && CGO_ENABLED=0 go build -trimpath \ + -ldflags "-s -w -X github.com/OpenSourceOM/core/internal/version.version=${VERSION} -X github.com/OpenSourceOM/core/internal/version.commit=${COMMIT} -X github.com/OpenSourceOM/core/internal/version.date=${DATE}" \ + -o /om ./cmd/om FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6 RUN apk add --no-cache ca-certificates diff --git a/README.md b/README.md index ab36397..27ab6b5 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ Full design: [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) ## Repository layout ``` -cmd/om/ `om` CLI (migrate, serve, scan, enrich, rules, identity, export, graph, paths) +cmd/om/ `om` CLI (version, migrate, serve, scan, enrich, rules, identity, export, graph, paths) sdk/collector/ Public SDK for external collector plugins examples/collector/ Sample plugin (`om scan plugin`) internal/collectors/ AWS, Azure, GCP, Kubernetes, demo graph @@ -87,6 +87,9 @@ docker compose up -d # Build the CLI go build -o om ./cmd/om +# Print this build. A release says 0.5.0. Uncommitted changes add +dirty. +./om version + # Load the sample graph (no cloud credentials) ./om scan demo @@ -128,13 +131,15 @@ open http://localhost:8080 The API image compiles `github.com/aws/aws-sdk-go-v2/service/ec2`. Give Docker at least 6 GiB of RAM. +The header shows the version of the running build. Open it, or Settings → About, for the git commit and a link to that commit. `GET /v1/version` returns the same information. A release shows its version, such as 0.5.0. A git checkout of that release shows the same version, with `+dirty` when the tree has uncommitted changes. + The console signs people in with an email and password. While no admin exists, the page asks you to create one. Keep the API port private until you do. Set `OM_ADMIN_EMAIL` when that first address should be fixed in advance. `om admin create --email you@example.com` does the same from the machine before the port is reachable. An admin adds the next person from the console. That person gets a temporary password, shown once, and chooses their own at the next sign-in. SMTP is optional. When it is set, the temporary password is emailed, and a forgotten password can be reset from a one-hour link. Google and GitHub buttons appear only when those client credentials are set, and the provider email must match an existing admin. Every console account is an admin. Two-factor authentication is optional per admin. -An API token is created only when an admin asks for one, from the console or with `om token create --email you@example.com`. It is printed once. Collectors, CI, and `curl` send it as `Authorization: Bearer` or `X-API-Key` on every `/v1` call except health, the session probe, and logout. The CLI talks to Postgres and does not use the token. A token cannot add or remove admins. `om token rotate --email you@example.com` replaces it. +An API token is created only when an admin asks for one, from the console or with `om token create --email you@example.com`. It is printed once. Collectors, CI, and `curl` send it as `Authorization: Bearer` or `X-API-Key` on every `/v1` call except health, version, the session probe, and logout. The CLI talks to Postgres and does not use the token. A token cannot add or remove admins. `om token rotate --email you@example.com` replaces it. -**API endpoints:** `GET /v1/health`, `POST /v1/ingest`, `GET /v1/findings`, `GET /v1/graph/snapshot`, `POST /v1/rules/run`, `GET /v1/identity/blast-radius` +**API endpoints:** `GET /v1/health`, `GET /v1/version`, `POST /v1/ingest`, `GET /v1/findings`, `GET /v1/graph/snapshot`, `POST /v1/rules/run`, `GET /v1/identity/blast-radius` **Multi-cloud scan:** diff --git a/api/README.md b/api/README.md index f6fe98b..6db21df 100644 --- a/api/README.md +++ b/api/README.md @@ -13,5 +13,6 @@ HTTP and GraphQL API for the web UI, collectors, and integrations. - `GET /v1/graph/query` — attack path and neighborhood queries - `GET /v1/findings` — prioritized findings with path context - `GET /v1/health` — readiness for orchestration +- `GET /v1/version` — version, git commit, and commit date of this build -Authentication: every `/v1/*` route except `GET /v1/health`, `GET /v1/auth/session`, `POST /v1/auth/logout`, `POST /v1/auth/setup`, `POST /v1/auth/login`, and `POST /v1/auth/totp` requires a console session cookie or an API token that admin created (`Authorization: Bearer` or `X-API-Key`). Admin routes (`/v1/admins`, including token create and rotation) require the session. People sign in with email and password. The first account is created from the setup form or `om admin create`. A token is created from the console or `om token create`, and is not issued at sign-in. Google and GitHub are optional and must match an existing admin email. Enterprise SAML and custom identity providers live outside this repo. +Authentication: every `/v1/*` route except `GET /v1/health`, `GET /v1/version`, `GET /v1/auth/session`, `POST /v1/auth/logout`, `POST /v1/auth/setup`, `POST /v1/auth/login`, and `POST /v1/auth/totp` requires a console session cookie or an API token that admin created (`Authorization: Bearer` or `X-API-Key`). Admin routes (`/v1/admins`, including token create and rotation) require the session. People sign in with email and password. The first account is created from the setup form or `om admin create`. A token is created from the console or `om token create`, and is not issued at sign-in. Google and GitHub are optional and must match an existing admin email. Enterprise SAML and custom identity providers live outside this repo. diff --git a/internal/api/console_auth.go b/internal/api/console_auth.go index 65e9d26..ad492f3 100644 --- a/internal/api/console_auth.go +++ b/internal/api/console_auth.go @@ -474,6 +474,8 @@ func publicV1(r *http.Request) bool { switch { case r.Method == http.MethodGet && r.URL.Path == "/v1/health": return true + case r.Method == http.MethodGet && r.URL.Path == "/v1/version": + return true case r.Method == http.MethodGet && r.URL.Path == "/v1/auth/session": return true case r.Method == http.MethodPost && r.URL.Path == "/v1/auth/logout": diff --git a/internal/api/server.go b/internal/api/server.go index b02baf7..a0b87f0 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -20,6 +20,7 @@ import ( "github.com/OpenSourceOM/core/internal/export" "github.com/OpenSourceOM/core/internal/graph" "github.com/OpenSourceOM/core/internal/rules" + "github.com/OpenSourceOM/core/internal/version" ) const ( @@ -58,6 +59,7 @@ func NewServer(store *graph.Store) *Server { func (s *Server) Handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /v1/health", s.handleHealth) + mux.HandleFunc("GET /v1/version", s.handleVersion) mux.HandleFunc("POST /v1/ingest", s.handleIngest) mux.HandleFunc("GET /v1/graph/stats", s.handleStats) mux.HandleFunc("GET /v1/graph/query", s.handleQuery) @@ -131,6 +133,11 @@ func (s *Server) ListenAndServe(addr string) error { return server.ListenAndServe() } +func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, http.StatusOK, version.Current()) +} + func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { if err := s.health.Ping(r.Context()); err != nil { writeJSON(w, http.StatusServiceUnavailable, map[string]string{ diff --git a/internal/api/server_test.go b/internal/api/server_test.go index f77bdc8..b0b87f6 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -77,6 +77,40 @@ func TestHandleHealth(t *testing.T) { } } +func TestVersionIsPublic(t *testing.T) { + handler := (&Server{}).Handler() + request := httptest.NewRequest(http.MethodGet, "/v1/version", nil) + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", recorder.Code, http.StatusOK) + } + if got := recorder.Header().Get("Cache-Control"); got != "no-store" { + t.Fatalf("cache-control = %q", got) + } + var body map[string]any + if err := json.NewDecoder(recorder.Body).Decode(&body); err != nil { + t.Fatalf("decode response: %v", err) + } + if body["name"] != "OpenSourceOM Core" { + t.Fatalf("name = %v", body["name"]) + } + for _, key := range []string{"version", "commit", "commit_short", "date", "modified"} { + if _, ok := body[key]; !ok { + t.Fatalf("missing %s in %#v", key, body) + } + } + if body["version"] == "" || body["commit"] == "" { + t.Fatalf("empty build identity: %#v", body) + } + if body["version"] == "dev" { + if _, ok := body["release_url"]; ok { + t.Fatal("dev builds must not advertise a release") + } + } +} + func TestPersonalTokenGatesV1Routes(t *testing.T) { directory := newMemoryDirectory() directory.rememberToken("secret", auth.Admin{ID: "admin-1", Provider: auth.ProviderGitHub, Subject: "1", Login: "ada"}) @@ -92,6 +126,7 @@ func TestPersonalTokenGatesV1Routes(t *testing.T) { wantStatus int }{ {name: "health open without key", method: http.MethodGet, path: "/v1/health", wantStatus: http.StatusOK}, + {name: "version open without key", method: http.MethodGet, path: "/v1/version", wantStatus: http.StatusOK}, {name: "rules list denied without key", method: http.MethodGet, path: "/v1/rules", wantStatus: http.StatusUnauthorized}, {name: "rules list allowed with api key", method: http.MethodGet, path: "/v1/rules", header: "X-API-Key", value: "secret", wantStatus: http.StatusOK}, {name: "rules list allowed with bearer", method: http.MethodGet, path: "/v1/rules", header: "Authorization", value: "Bearer secret", wantStatus: http.StatusOK}, diff --git a/internal/api/web/app.js b/internal/api/web/app.js index f2e913f..a4356f4 100644 --- a/internal/api/web/app.js +++ b/internal/api/web/app.js @@ -1023,7 +1023,90 @@ document.getElementById("admins-btn").addEventListener("click", () => { renderAdmins().catch(showConsoleError); }); +function versionLabel(info) { + const version = info.version || "dev"; + if (version !== "dev" || !info.commit_short || info.commit_short === "unknown") return version; + const short = info.modified ? `${info.commit_short}-dirty` : info.commit_short; + return `dev · ${short}`; +} + +function formatVersionDate(value) { + if (!value || value === "unknown") return "unknown"; + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) return value; + return parsed.toLocaleString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + timeZone: "UTC", + timeZoneName: "short", + }); +} + +function fillAbout(info) { + const version = info.version || "dev"; + const label = document.getElementById("version-label"); + label.textContent = versionLabel(info); + const button = document.getElementById("about-btn"); + button.classList.remove("hidden"); + button.setAttribute("aria-label", `About OpenSourceOM Core, version ${version}`); + document.getElementById("about-version").textContent = version; + + const commit = document.getElementById("about-commit"); + commit.replaceChildren(); + const short = info.commit_short || info.commit || "unknown"; + if (info.commit_url) { + const link = document.createElement("a"); + link.href = info.commit_url; + link.target = "_blank"; + link.rel = "noopener noreferrer"; + link.textContent = short; + if (info.commit) link.title = info.commit; + commit.appendChild(link); + } else { + commit.textContent = short; + } + if (info.modified && short !== "unknown" && !version.endsWith("+dirty")) { + commit.appendChild(document.createTextNode(" (modified)")); + } + + document.getElementById("about-date").textContent = formatVersionDate(info.date); + const links = document.getElementById("about-links"); + links.replaceChildren(); + if (info.release_url) { + const notes = document.createElement("a"); + notes.href = info.release_url; + notes.target = "_blank"; + notes.rel = "noopener noreferrer"; + notes.textContent = "Release notes"; + links.appendChild(notes); + } +} + +function openAbout() { + closeSettingsMenu(); + const dialog = document.getElementById("about-dialog"); + if (!dialog.open) dialog.showModal(); +} + +async function loadVersion() { + try { + fillAbout(await fetchJSON("/v1/version")); + } catch { + return; + } +} + +document.getElementById("about-btn").addEventListener("click", openAbout); +document.getElementById("about-menu-btn").addEventListener("click", openAbout); +document.getElementById("about-dialog").addEventListener("click", (event) => { + if (event.target === event.currentTarget) event.currentTarget.close(); +}); + (async function init() { + loadVersion(); localStorage.removeItem("om_api_key"); try { const session = await fetchJSON("/v1/auth/session"); diff --git a/internal/api/web/index.html b/internal/api/web/index.html index 0ed57aa..f22abf8 100644 --- a/internal/api/web/index.html +++ b/internal/api/web/index.html @@ -29,6 +29,9 @@

OpenSourceOM

Graph-native cloud security

+ @@ -130,6 +134,29 @@

Graph Explorer

+ +
+

About

+

OpenSourceOM Core

+
+
+
Version
+
Unknown
+
+
+
Commit
+
Unknown
+
+
+
Date
+
Unknown
+
+
+ + +
+
+ diff --git a/internal/api/web/styles.css b/internal/api/web/styles.css index 5f2458c..b31ec2c 100644 --- a/internal/api/web/styles.css +++ b/internal/api/web/styles.css @@ -109,6 +109,29 @@ header::after { font-family: var(--font-mono); font-size: 0.72rem; } +.version-chip { + align-self: center; + flex-shrink: 0; + margin: 0; + padding: 0.18rem 0.55rem; + border: 1px solid var(--border); + border-radius: 999px; + background: rgba(13, 20, 32, 0.85); + color: var(--muted); + font-family: var(--font-mono); + font-size: 0.72rem; + font-weight: 500; + line-height: 1.4; +} +.version-chip:hover, +.version-chip:focus-visible { + border-color: var(--accent); + color: var(--accent); +} +.version-chip:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} .account { display: flex; @@ -250,6 +273,57 @@ header::after { background: var(--border); } +.about-dialog { + width: min(26rem, calc(100vw - 2rem)); + max-height: calc(100vh - 2rem); + margin: auto; + padding: 0; + border: 1px solid var(--border); + border-radius: 18px; + background: + linear-gradient(180deg, rgba(34, 211, 238, 0.07), transparent 34%), + rgba(17, 24, 39, 0.96); + color: var(--text); + box-shadow: var(--shadow); +} +.about-dialog::backdrop { + background: rgba(7, 11, 18, 0.72); + backdrop-filter: blur(4px); +} +.about-dialog form { padding: 1.4rem 1.4rem 1.25rem; } +.about-dialog h2 { + margin: 0 0 1rem; + font-size: 1.25rem; + letter-spacing: -0.03em; +} +.about-dialog dl { + margin: 0; + display: grid; + gap: 0.65rem; +} +.about-dialog dl div { + display: grid; + grid-template-columns: 5.5rem 1fr; + gap: 0.75rem; + align-items: baseline; +} +.about-dialog dt { + margin: 0; + color: var(--muted); + font-size: 0.78rem; +} +.about-dialog dd { + margin: 0; + font-family: var(--font-mono); + font-size: 0.84rem; + overflow-wrap: anywhere; +} +.about-dialog a { color: var(--accent); text-decoration: none; } +.about-dialog a:hover { text-decoration: underline; } +.about-links { margin: 1rem 0 0; } +.about-links:empty { display: none; } +.about-dialog .btn { margin-top: 1.1rem; } + .eyebrow, .panel-kicker { margin: 0 0 0.4rem; color: var(--accent); diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 6708a69..cf8256e 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -13,6 +13,7 @@ var rootCmd = &cobra.Command{ } func Execute() error { + rootCmd.AddCommand(versionCmd) rootCmd.AddCommand(migrateCmd) rootCmd.AddCommand(serveCmd) rootCmd.AddCommand(adminCmd) diff --git a/internal/cmd/version.go b/internal/cmd/version.go new file mode 100644 index 0000000..340b531 --- /dev/null +++ b/internal/cmd/version.go @@ -0,0 +1,24 @@ +// Copyright 2026 OpenSourceOM +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "fmt" + + "github.com/OpenSourceOM/core/internal/version" + "github.com/spf13/cobra" +) + +var versionCmd = &cobra.Command{ + Use: "version", + Short: "Print the OpenSourceOM Core version and commit", + Run: func(cmd *cobra.Command, args []string) { + fmt.Fprintln(cmd.OutOrStdout(), version.Current().String()) + }, +} + +func init() { + rootCmd.Version = version.Current().String() + rootCmd.SetVersionTemplate("{{.Version}}\n") +} diff --git a/internal/cmd/version_test.go b/internal/cmd/version_test.go new file mode 100644 index 0000000..7332667 --- /dev/null +++ b/internal/cmd/version_test.go @@ -0,0 +1,20 @@ +// Copyright 2026 OpenSourceOM +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "bytes" + "strings" + "testing" +) + +func TestVersionCommandPrintsBuild(t *testing.T) { + var out bytes.Buffer + versionCmd.SetOut(&out) + versionCmd.Run(versionCmd, nil) + got := out.String() + if !strings.Contains(got, "OpenSourceOM Core ") || !strings.Contains(got, "\ncommit ") || !strings.Contains(got, "\ndate ") { + t.Fatalf("output = %q", got) + } +} diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 0000000..f6dfbf6 --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,181 @@ +// Copyright 2026 OpenSourceOM +// SPDX-License-Identifier: Apache-2.0 + +// Package version reports the build that is running. +// +// Release builds set version, commit, and date with -ldflags -X. +// A build without those flags uses the VCS metadata Go embeds, then "dev". +package version + +import ( + "fmt" + "regexp" + "runtime/debug" + "strings" +) + +const ( + Name = "OpenSourceOM Core" + repository = "https://github.com/OpenSourceOM/core" +) + +// Set with -ldflags "-X github.com/OpenSourceOM/core/internal/version.version=0.5.0". +var ( + version = "" + commit = "" + date = "" +) + +var ( + releaseVersion = regexp.MustCompile(`^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z][0-9A-Za-z.-]*))?$`) + pseudoVersion = regexp.MustCompile(`^[0-9]{14}-[0-9a-f]+$`) + pseudoTimestamp = regexp.MustCompile(`[0-9]{14}`) +) + +// Build is the version a person can compare with a release, plus the commit +// that produced the binary. +type Build struct { + Name string `json:"name"` + Version string `json:"version"` + Commit string `json:"commit"` + CommitShort string `json:"commit_short"` + Date string `json:"date"` + Modified bool `json:"modified"` + CommitURL string `json:"commit_url,omitempty"` + ReleaseURL string `json:"release_url,omitempty"` +} + +// Current returns this process's build. +func Current() Build { + moduleVersion := "" + settings := map[string]string{} + if info, ok := debug.ReadBuildInfo(); ok { + moduleVersion = info.Main.Version + for _, setting := range info.Settings { + settings[setting.Key] = setting.Value + } + } + return resolve(version, commit, date, moduleVersion, settings) +} + +func resolve(version, commit, date, moduleVersion string, settings map[string]string) Build { + injectedCommit := strings.TrimSpace(commit) != "" + version = strings.TrimSpace(version) + if version == "" { + candidate := strings.TrimSpace(moduleVersion) + if candidate != "" && candidate != "(devel)" { + version = candidate + } + } + version = normalizeVersion(version) + if version == "" { + version = "dev" + } + + commit = normalizeCommit(commit) + if commit == "" { + commit = normalizeCommit(settings["vcs.revision"]) + } + if commit == "" { + commit = "unknown" + } + + date = strings.TrimSpace(date) + if date == "" { + date = strings.TrimSpace(settings["vcs.time"]) + } + if date == "" { + date = "unknown" + } + + build := Build{ + Name: Name, + Version: version, + Commit: commit, + CommitShort: shortCommit(commit), + Date: date, + Modified: !injectedCommit && settings["vcs.modified"] == "true", + } + if url := commitURL(commit); url != "" { + build.CommitURL = url + } + if url := releaseURL(version); url != "" { + build.ReleaseURL = url + } + return build +} + +func (b Build) String() string { + commit := b.Commit + if b.Modified && commit != "unknown" && !strings.HasSuffix(b.Version, "+dirty") { + commit += " (modified)" + } + return fmt.Sprintf("%s %s\ncommit %s\ndate %s", b.Name, b.Version, commit, b.Date) +} + +func normalizeVersion(raw string) string { + raw = strings.TrimSpace(raw) + if len(raw) >= 2 && (raw[0] == 'v' || raw[0] == 'V') && raw[1] >= '0' && raw[1] <= '9' { + return raw[1:] + } + return raw +} + +func normalizeCommit(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + if isHex(raw) { + return strings.ToLower(raw) + } + return raw +} + +func shortCommit(commit string) string { + if !isHex(commit) { + return commit + } + if len(commit) > 7 { + return commit[:7] + } + return commit +} + +func commitURL(commit string) string { + if !isHex(commit) || len(commit) < 7 { + return "" + } + return repository + "/commit/" + commit +} + +func releaseURL(version string) string { + base, _, _ := strings.Cut(version, "+") + if pseudoTimestamp.MatchString(base) { + return "" + } + match := releaseVersion.FindStringSubmatch(base) + if match == nil { + return "" + } + if match[4] != "" && pseudoVersion.MatchString(match[4]) { + return "" + } + return repository + "/releases/tag/v" + base +} + +func isHex(value string) bool { + if value == "" { + return false + } + for _, r := range value { + switch { + case r >= '0' && r <= '9': + case r >= 'a' && r <= 'f': + case r >= 'A' && r <= 'F': + default: + return false + } + } + return true +} diff --git a/internal/version/version_test.go b/internal/version/version_test.go new file mode 100644 index 0000000..d14ff9a --- /dev/null +++ b/internal/version/version_test.go @@ -0,0 +1,117 @@ +// Copyright 2026 OpenSourceOM +// SPDX-License-Identifier: Apache-2.0 + +package version + +import ( + "strings" + "testing" +) + +func TestResolveReleaseBuild(t *testing.T) { + got := resolve("v0.5.0", "ABCDEF1234567890", "2026-10-26T22:00:00Z", "(devel)", map[string]string{ + "vcs.revision": "ffffffffffffffff", + "vcs.modified": "true", + "vcs.time": "2020-01-01T00:00:00Z", + }) + if got.Name != Name || got.Version != "0.5.0" { + t.Fatalf("name/version = %s %s", got.Name, got.Version) + } + if got.Commit != "abcdef1234567890" || got.CommitShort != "abcdef1" { + t.Fatalf("commit = %s %s", got.Commit, got.CommitShort) + } + if got.Date != "2026-10-26T22:00:00Z" { + t.Fatalf("date = %s", got.Date) + } + if got.Modified { + t.Fatal("an injected commit must not inherit a dirty workspace") + } + if got.CommitURL != "https://github.com/OpenSourceOM/core/commit/abcdef1234567890" { + t.Fatalf("commit url = %s", got.CommitURL) + } + if got.ReleaseURL != "https://github.com/OpenSourceOM/core/releases/tag/v0.5.0" { + t.Fatalf("release url = %s", got.ReleaseURL) + } + text := got.String() + if !strings.Contains(text, "OpenSourceOM Core 0.5.0") || !strings.Contains(text, "commit abcdef1234567890") { + t.Fatalf("string = %q", text) + } +} + +func TestResolveSourceBuildUsesVCS(t *testing.T) { + got := resolve("", "", "", "(devel)", map[string]string{ + "vcs.revision": "abc1234def5678", + "vcs.modified": "true", + "vcs.time": "2026-10-26T22:00:00Z", + }) + if got.Version != "dev" { + t.Fatalf("version = %s", got.Version) + } + if got.Commit != "abc1234def5678" || !got.Modified { + t.Fatalf("commit = %+v", got) + } + if got.ReleaseURL != "" { + t.Fatalf("dev build advertised a release: %s", got.ReleaseURL) + } + if !strings.Contains(got.String(), "abc1234def5678 (modified)") { + t.Fatalf("string = %q", got.String()) + } +} + +func TestResolveModuleVersionAndPseudoVersion(t *testing.T) { + release := resolve("", "", "", "v0.5.0", nil) + if release.Version != "0.5.0" || release.ReleaseURL == "" { + t.Fatalf("module version = %+v", release) + } + pseudo := resolve("", "", "", "v0.0.0-20261026120000-abcdef123456", nil) + if pseudo.Version != "0.0.0-20261026120000-abcdef123456" { + t.Fatalf("version = %s", pseudo.Version) + } + if pseudo.ReleaseURL != "" { + t.Fatalf("pseudo-version advertised a release: %s", pseudo.ReleaseURL) + } + if pseudo.Commit != "unknown" || pseudo.Date != "unknown" { + t.Fatalf("missing vcs = %+v", pseudo) + } +} + +func TestResolveTaggedCheckout(t *testing.T) { + got := resolve("", "", "", "v0.5.0+dirty", map[string]string{ + "vcs.revision": "abc1234def5678", + "vcs.modified": "true", + "vcs.time": "2026-10-26T22:00:00Z", + }) + if got.Version != "0.5.0+dirty" || !got.Modified { + t.Fatalf("%+v", got) + } + if got.ReleaseURL != "https://github.com/OpenSourceOM/core/releases/tag/v0.5.0" { + t.Fatalf("release url = %s", got.ReleaseURL) + } + if strings.Contains(got.String(), "(modified)") { + t.Fatalf("dirty version was repeated on the commit: %q", got.String()) + } +} + +func TestResolveRejectsUnreleasedVersions(t *testing.T) { + for _, version := range []string{"dev", "nightly", "1", "0.5", "v1.2", "0.5.1-0.20261027120000-abcdef123456"} { + if got := releaseURL(normalizeVersion(version)); got != "" { + t.Fatalf("version %s linked to %s", version, got) + } + } + if got := releaseURL("0.5.0-rc.1"); got != "https://github.com/OpenSourceOM/core/releases/tag/v0.5.0-rc.1" { + t.Fatalf("prerelease url = %s", got) + } + if got := releaseURL("0.5.0+dirty"); got != "https://github.com/OpenSourceOM/core/releases/tag/v0.5.0" { + t.Fatalf("dirty release url = %s", got) + } + if commitURL("unknown") != "" || commitURL("abc") != "" || shortCommit("unknown") != "unknown" { + t.Fatal("non-commits should stay plain text") + } +} + +func TestCurrentReportsAVersion(t *testing.T) { + got := Current() + if got.Name != Name || got.Version == "" || got.Commit == "" || got.CommitShort == "" || got.Date == "" { + t.Fatalf("%+v", got) + } +}