diff --git a/.gitea/scripts/publish-tap.sh b/.gitea/scripts/publish-tap.sh new file mode 100755 index 0000000..ba64d89 --- /dev/null +++ b/.gitea/scripts/publish-tap.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +# Renders Homebrew Formula + Scoop manifest files for this release and +# pushes them into the shared tap repo (see .gitea/workflows/release.yml). +# Other tools publishing into the same tap repo can copy this script +# verbatim — only SRC_HOMEPAGE/BIN_NAME/DESC below need to change. +set -euo pipefail + +: "${VERSION:?VERSION (e.g. v1.2.3) is required}" +: "${TAP_TOKEN:?TAP_TOKEN secret is required}" + +TAP_REPO_URL="https://tap:${TAP_TOKEN}@git.morlana.online/f.weber/homebrew-tap.git" +TAP_BRANCH="main" + +BIN_NAME="vault-tui" +DESC="Terminal UI and CLI for HashiCorp Vault" +SRC_HOMEPAGE="https://git.morlana.online/f.weber/vault-tui" +RELEASE_BASE="${SRC_HOMEPAGE}/releases/download/${VERSION}" +DIST="dist" +VER_NUM="${VERSION#v}" # Formula/manifest "version" fields conventionally omit the leading v + +sha() { # sha + grep " $2\$" "$1" | awk '{print $1}' +} + +CK="${DIST}/checksums-${BIN_NAME}-${VERSION}.txt" +CK_CLOUD="${DIST}/checksums-${BIN_NAME}-cloud-${VERSION}.txt" + +SHA_LINUX_AMD64=$(sha "$CK" "${BIN_NAME}-${VERSION}-linux-amd64.tar.gz") +SHA_LINUX_ARM64=$(sha "$CK" "${BIN_NAME}-${VERSION}-linux-arm64.tar.gz") +SHA_DARWIN_AMD64=$(sha "$CK" "${BIN_NAME}-${VERSION}-darwin-amd64.tar.gz") +SHA_DARWIN_ARM64=$(sha "$CK" "${BIN_NAME}-${VERSION}-darwin-arm64.tar.gz") +SHA_WINDOWS_AMD64=$(sha "$CK" "${BIN_NAME}-${VERSION}-windows-amd64.zip") + +SHA_CLOUD_LINUX_AMD64=$(sha "$CK_CLOUD" "${BIN_NAME}-cloud-${VERSION}-linux-amd64.tar.gz") +SHA_CLOUD_LINUX_ARM64=$(sha "$CK_CLOUD" "${BIN_NAME}-cloud-${VERSION}-linux-arm64.tar.gz") +SHA_CLOUD_DARWIN_AMD64=$(sha "$CK_CLOUD" "${BIN_NAME}-cloud-${VERSION}-darwin-amd64.tar.gz") +SHA_CLOUD_DARWIN_ARM64=$(sha "$CK_CLOUD" "${BIN_NAME}-cloud-${VERSION}-darwin-arm64.tar.gz") +SHA_CLOUD_WINDOWS_AMD64=$(sha "$CK_CLOUD" "${BIN_NAME}-cloud-${VERSION}-windows-amd64.zip") + +WORKDIR=$(mktemp -d) +trap 'rm -rf "$WORKDIR"' EXIT + +git clone --depth 1 --branch "$TAP_BRANCH" "$TAP_REPO_URL" "$WORKDIR" +cd "$WORKDIR" +mkdir -p Formula bucket + +render_formula() { # render_formula + local class="$1" prefix="$2" install_name="$3" + local sha_l_amd sha_l_arm sha_d_amd sha_d_arm + if [ "$prefix" = "${BIN_NAME}-cloud" ]; then + sha_l_amd="$SHA_CLOUD_LINUX_AMD64"; sha_l_arm="$SHA_CLOUD_LINUX_ARM64" + sha_d_amd="$SHA_CLOUD_DARWIN_AMD64"; sha_d_arm="$SHA_CLOUD_DARWIN_ARM64" + else + sha_l_amd="$SHA_LINUX_AMD64"; sha_l_arm="$SHA_LINUX_ARM64" + sha_d_amd="$SHA_DARWIN_AMD64"; sha_d_arm="$SHA_DARWIN_ARM64" + fi + cat < "${install_name}" } + end + + test do + assert_match version.to_s, shell_output("#{bin}/${install_name} version") + end +end +EOF +} + +render_scoop() { # render_scoop + local prefix="$1" exe_name="$2" sha="$3" + cat <Formula/vault-tui.rb +render_formula "VaultTuiCloud" "${BIN_NAME}-cloud" "${BIN_NAME}-cloud" >Formula/vault-tui-cloud.rb +render_scoop "${BIN_NAME}" "vault-tui" "${SHA_WINDOWS_AMD64}" >bucket/vault-tui.json +render_scoop "${BIN_NAME}-cloud" "vault-tui-cloud" "${SHA_CLOUD_WINDOWS_AMD64}" >bucket/vault-tui-cloud.json + +git config user.name "vault-tui-release" +git config user.email "vault-tui-bot@users.noreply.git.morlana.online" +git add Formula/vault-tui.rb Formula/vault-tui-cloud.rb bucket/vault-tui.json bucket/vault-tui-cloud.json + +if git diff --cached --quiet; then + echo "tap already up to date with ${VERSION}, nothing to push" + exit 0 +fi + +git commit -m "vault-tui ${VERSION}" +git push origin "HEAD:${TAP_BRANCH}" +echo "pushed vault-tui ${VERSION} to the tap" diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index abf43e1..eb00f3c 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -26,9 +26,12 @@ jobs: run: make test - name: Build all artifacts (default + cloud, all platforms) + env: + VERSION: ${{ gitea.event.release.tag_name }} + PRERELEASE: ${{ gitea.event.release.prerelease }} run: | - make release - make release-cloud + make release VERSION="$VERSION" PRERELEASE="$PRERELEASE" + make release-cloud VERSION="$VERSION" PRERELEASE="$PRERELEASE" - name: Upload artifacts to release env: @@ -43,3 +46,13 @@ jobs: -F "attachment=@${f};filename=${name}" \ "${api}?name=${name}" done + + # Homebrew/Linuxbrew tap + Scoop bucket: only for stable releases — + # a prerelease shouldn't become what `brew install`/`scoop install` + # resolve to by default. See .gitea/scripts/publish-tap.sh. + - name: Publish Homebrew/Scoop tap + if: gitea.event.release.prerelease != true + env: + VERSION: ${{ gitea.event.release.tag_name }} + TAP_TOKEN: ${{ secrets.TAP_TOKEN }} + run: .gitea/scripts/publish-tap.sh diff --git a/.gitea/workflows/test.yaml b/.gitea/workflows/test.yaml new file mode 100644 index 0000000..6bf755a --- /dev/null +++ b/.gitea/workflows/test.yaml @@ -0,0 +1,27 @@ +name: Test + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + testing: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Test + run: make check diff --git a/Makefile b/Makefile index 3c98ddb..988a94c 100644 --- a/Makefile +++ b/Makefile @@ -7,10 +7,11 @@ DIST := dist PREFIX ?= $(HOME)/.local GOBIN ?= $(PREFIX)/bin -VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) +VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo none) DATE := $(shell date -u +%Y-%m-%dT%H:%M:%SZ) -LDFLAGS := -s -w -X main.version=$(VERSION) +PRERELEASE ?= false +LDFLAGS := -s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(DATE) -X main.prerelease=$(PRERELEASE) GOFLAGS := -trimpath CGO_ENABLED ?= 0 @@ -31,7 +32,7 @@ help: @echo "" @awk 'BEGIN {FS = ":.*## "} /^[a-zA-Z0-9_-]+:.*## / {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST) @echo "" - @echo "Variables: TAGS=cloud (adds cloud auth to any target), PREFIX, GOBIN, PLATFORMS, VERSION" + @echo "Variables: TAGS=cloud (adds cloud auth to any target), PREFIX, GOBIN, PLATFORMS, VERSION, PRERELEASE" ## build: build the default binary (no cloud auth) into dist/ build: ## build the default binary (no cloud auth) into dist/ diff --git a/cmd/vault-tui/main.go b/cmd/vault-tui/main.go index c24fbdd..3632924 100644 --- a/cmd/vault-tui/main.go +++ b/cmd/vault-tui/main.go @@ -10,11 +10,23 @@ import ( _ "git.morlana.online/f.weber/vault-tui/internal/ui" // registers the "ui" command's runner ) -// version is overridden at build time via -ldflags "-X main.version=...". -var version = "dev" +// version, commit, date, and prerelease are overridden at build time via +// -ldflags (see the Makefile's LDFLAGS and the release Gitea Actions +// workflow, which sets PRERELEASE from the triggering release's flag). +var ( + version = "dev" + commit = "none" + date = "unknown" + prerelease = "false" +) func main() { - root := cli.Root(version) + root := cli.Root(cli.BuildInfo{ + Version: version, + Commit: commit, + Date: date, + Prerelease: prerelease == "true", + }) if err := root.Run(context.Background(), os.Args); err != nil { fmt.Fprintln(os.Stderr, "error:", err) os.Exit(1) diff --git a/internal/cli/app.go b/internal/cli/app.go index c4019ba..4d177a9 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -91,11 +91,12 @@ func bootstrap(cmd *cli.Command) (*App, error) { // First-run UX: if there is genuinely no config yet, an interactive // session gets an inline wizard instead of a wall of "no profile // configured" errors — but never for `config ...` itself (that - // subtree has its own explicit `init`) and never in a non-interactive - // context (CI, pipes), where the tool must stay driven entirely by - // flags/env instead of blocking on stdin. + // subtree has its own explicit `init`), never for `version` (which + // shouldn't need a Vault connection at all), and never in a + // non-interactive context (CI, pipes), where the tool must stay + // driven entirely by flags/env instead of blocking on stdin. leaf := cmd.Args().First() - if !config.Exists(path) && IsInteractive() && leaf != "config" { + if !config.Exists(path) && IsInteractive() && leaf != "config" && leaf != "version" { if _, err := runWizard(path); err != nil { return nil, err } diff --git a/internal/cli/root.go b/internal/cli/root.go index af0e532..1173a53 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -9,11 +9,12 @@ import ( // Root builds the full command tree. DefaultCommand launches the TUI when // vault-tui is invoked with no subcommand, matching the plan's "possible // without any subcommand" requirement. -func Root(version string) *cli.Command { +func Root(b BuildInfo) *cli.Command { + build = b root := &cli.Command{ Name: "vault-tui", Usage: "Terminal UI for HashiCorp Vault", - Version: version, + Version: b.Version, DefaultCommand: "ui", // Deliberately no Sources: cli.EnvVars(...) here: internal/config.Resolve // (and config.ResolvePath/SelectProfile for --config/--profile) already @@ -49,6 +50,7 @@ func Root(version string) *cli.Command { loginCommand(), logoutCommand(), statusCommand(), + versionCommand(), listCommand(), readCommand(), writeCommand(), diff --git a/internal/cli/version.go b/internal/cli/version.go new file mode 100644 index 0000000..ac61949 --- /dev/null +++ b/internal/cli/version.go @@ -0,0 +1,64 @@ +package cli + +import ( + "context" + "fmt" + "os" + + "github.com/urfave/cli/v3" +) + +// BuildInfo carries version metadata baked in at build time via -ldflags +// (see the Makefile's LDFLAGS/PRERELEASE variables and the release Gitea +// Actions workflow, which sets PRERELEASE from the triggering release's +// "prerelease" flag). Root stores the value it was given so versionCommand +// and internal/ui's footer (via Build) can read it back without threading +// it through every call site. +type BuildInfo struct { + Version string // git tag (or "dev" for untagged/local builds) + Commit string + Date string + Prerelease bool +} + +var build BuildInfo + +// Build returns the BuildInfo Root was constructed with. +func Build() BuildInfo { return build } + +// String renders "1.2.3 (commit abc1234, built 2026-08-14T12:00:00Z)", +// appending " [prerelease]" when the triggering Gitea release was marked +// as a prerelease. +func (b BuildInfo) String() string { + s := fmt.Sprintf("%s (commit %s, built %s)", valueOr(b.Version, "dev"), valueOr(b.Commit, "none"), valueOr(b.Date, "unknown")) + if b.Prerelease { + s += " [prerelease]" + } + return s +} + +// versionCommand prints build metadata. Unlike every other command it does +// not touch a.Client/a.Store — see bootstrap's leaf-skip check, which keeps +// `vault-tui version` from tripping the first-run config wizard. +func versionCommand() *cli.Command { + return &cli.Command{ + Name: "version", + Usage: "print version, commit, build date, and prerelease status", + Flags: []cli.Flag{ + &cli.BoolFlag{Name: "json"}, + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + b := Build() + if cmd.Bool("json") { + return printJSON(os.Stdout, map[string]any{ + "version": b.Version, + "commit": b.Commit, + "date": b.Date, + "prerelease": b.Prerelease, + }) + } + fmt.Println(b.String()) + return nil + }, + } +} diff --git a/internal/ui/chrome.go b/internal/ui/chrome.go index b1e6a33..1a22b36 100644 --- a/internal/ui/chrome.go +++ b/internal/ui/chrome.go @@ -6,6 +6,8 @@ import ( "charm.land/bubbles/v2/help" "charm.land/bubbles/v2/key" "charm.land/lipgloss/v2" + + "git.morlana.online/f.weber/vault-tui/internal/cli" ) // screenHelp is the shape every keys.KeyMap.XxxHelp() screen-help value @@ -156,7 +158,12 @@ func (m *Model) viewHeader() string { // status line (profile/address, or the current toast) — one line total on // sizeTiny, two otherwise. func (m *Model) viewFooter(sh screenHelp) string { - status := fmt.Sprintf("%s @ %s", m.a.Settings.Profile, m.a.Settings.Address) + b := cli.Build() + ver := b.Version + if b.Prerelease { + ver += " (prerelease)" + } + status := fmt.Sprintf("%s @ %s · %s", m.a.Settings.Profile, m.a.Settings.Address, ver) if toastStr := m.toastView(); toastStr != "" { status = toastStr } else {