From eda5ad7ade8c82340d5fc44436f96346ffd1fcb0 Mon Sep 17 00:00:00 2001 From: Dejan Menges Date: Tue, 4 Aug 2026 15:36:27 +0200 Subject: [PATCH] Adding install script --- README.md | 39 ++++++++-- cmd/git-stats/main.go | 2 +- install.sh | 119 +++++++++++++++++++++++++++++++ internal/version/version.go | 35 ++++++++- internal/version/version_test.go | 31 ++++++++ 5 files changed, 216 insertions(+), 10 deletions(-) create mode 100755 install.sh create mode 100644 internal/version/version_test.go diff --git a/README.md b/README.md index 780907a..1950cbe 100644 --- a/README.md +++ b/README.md @@ -16,16 +16,35 @@ So this tool snapshots those endpoints, archives every raw response, and derives trends from the archive. Everything stays on your machine — there is no service, no account and no telemetry. -## Quick start +## Install ```sh -go build -o bin/git-stats ./cmd/git-stats +curl -fsSL https://raw.githubusercontent.com/dejo1307/git-stats/main/install.sh | sh +``` + +Installs the latest release into `$HOME/.local/bin`, verifying the published +SHA-256 checksum before it does. Set `GIT_STATS_INSTALL_DIR` to install elsewhere, +or `GIT_STATS_VERSION` to pin a version. Linux, macOS (Intel and Apple Silicon) and +Windows via Git Bash / MSYS2 are all covered. +Prefer to build it yourself, or want a platform the releases don't cover: + +```sh +go install github.com/dejo1307/git-stats/cmd/git-stats@latest +``` + +Binaries for every platform are also attached to each +[release](https://github.com/dejo1307/git-stats/releases) as +`git-stats---.tar.gz`, each with a `.sha256` beside it. + +## Quick start + +```sh cp .env.example .env && chmod 600 .env -$EDITOR .env # set GIT_STATS_REPO=owner/name +$EDITOR .env # set GIT_STATS_REPO=owner/name -./bin/git-stats collect # take a snapshot -./bin/git-stats report # see where things stand +git-stats collect # take a snapshot +git-stats report # see where things stand ``` `collect` works with no token at all for release counters. To get traffic data you @@ -279,7 +298,15 @@ architecture with `file` before publishing, so a silent fallback to the host architecture fails the build instead of shipping a mislabelled binary. Release assets follow the same `---.` scheme -git-stats parses, so it can track its own releases with no configuration. +git-stats parses, so it can track its own releases with no configuration. And +because [install.sh](install.sh) fetches each artifact's `.sha256` alongside it, +git-stats' own "scripted installs" figure means what it claims to. + +[install.sh](install.sh) is POSIX `sh`, deliberately — no bashisms and no +`set -o pipefail`. A piped script never honours its own shebang; the interpreter +on the left of the pipe runs it, and on Debian and Ubuntu that is dash, which +rejects `pipefail` outright. A bash-only installer dies on `| sh` before it does +any real work, on the most common Linux systems there are. CI on every pull request runs tests with the race detector, the same six-target cross-build, `golangci-lint`, `govulncheck`, and an architectural regression gate diff --git a/cmd/git-stats/main.go b/cmd/git-stats/main.go index 7c3c467..cadaad7 100644 --- a/cmd/git-stats/main.go +++ b/cmd/git-stats/main.go @@ -104,7 +104,7 @@ func run(args []string) error { case "rebuild": return runRebuild(args[1:]) case "version", "-version", "--version": - fmt.Println("git-stats", version.Version) + fmt.Println("git-stats", version.String()) return nil case "help", "-h", "--help": usage() diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..ee6bb65 --- /dev/null +++ b/install.sh @@ -0,0 +1,119 @@ +#!/bin/sh +# install.sh — Install the latest git-stats release. +# +# Usage: +# curl -fsSL https://raw.githubusercontent.com/dejo1307/git-stats/main/install.sh | sh +# +# Environment: +# GIT_STATS_VERSION install this version instead of the latest (e.g. 0.1.0) +# GIT_STATS_INSTALL_DIR install here instead of $HOME/.local/bin +# +# This script is deliberately POSIX sh, with no bashisms and no `set -o pipefail`. +# A piped script never gets to honour its own shebang — the interpreter on the +# left of the pipe runs it — and on Debian and Ubuntu /bin/sh is dash, which +# rejects `set -o pipefail` outright. A bash-only installer therefore dies before +# its first line of real work on the most common Linux systems. Keep it POSIX. + +set -eu + +REPO="dejo1307/git-stats" + +fail() { + echo "Error: $*" >&2 + exit 1 +} + +# --- Detect OS --- +OS="$(uname -s)" +case "$OS" in + Linux) OS=linux ;; + Darwin) OS=darwin ;; + # Git Bash, MSYS2 and Cygwin all report a decorated name; git-stats ships + # Windows binaries, so install rather than refuse. + MINGW*|MSYS*|CYGWIN*) OS=windows ;; + *) fail "unsupported OS: $OS — see https://github.com/$REPO/releases for a manual download" ;; +esac + +# --- Detect architecture --- +ARCH="$(uname -m)" +case "$ARCH" in + x86_64|amd64) ARCH=amd64 ;; + arm64|aarch64) ARCH=arm64 ;; + *) fail "unsupported architecture: $ARCH — see https://github.com/$REPO/releases" ;; +esac + +# --- Resolve version --- +VERSION="${GIT_STATS_VERSION:-}" +if [ -z "$VERSION" ]; then + VERSION="$(curl -fsSL "https://api.github.com/repos/$REPO/releases/latest" \ + | grep -m1 '"tag_name"' \ + | sed -e 's/.*"tag_name"[[:space:]]*:[[:space:]]*"v\{0,1\}//' -e 's/".*//')" +fi +[ -n "$VERSION" ] || fail "could not determine the latest version" +VERSION="${VERSION#v}" + +BASE="git-stats-${VERSION}-${OS}-${ARCH}" +ASSET="${BASE}.tar.gz" +SHASUM="${BASE}.sha256" +DL="https://github.com/$REPO/releases/download/v${VERSION}" + +echo "==> Downloading git-stats v${VERSION} for ${OS}/${ARCH} ..." + +TMP="$(mktemp -d)" +# shellcheck disable=SC2064 # expand TMP now, while it is still set +trap "rm -rf \"$TMP\"" EXIT INT TERM + +curl -fsSL -o "$TMP/$ASSET" "$DL/$ASSET" \ + || fail "no release asset $ASSET — check https://github.com/$REPO/releases" +curl -fsSL -o "$TMP/$SHASUM" "$DL/$SHASUM" \ + || fail "no checksum $SHASUM for $ASSET" + +# --- Verify --- +# The checksum is fetched and checked, never skipped: a truncated or tampered +# download that still extracts is exactly what this catches. sha256sum is the +# GNU/Linux name, shasum the one macOS ships. +echo "==> Verifying checksum ..." +if command -v sha256sum >/dev/null 2>&1; then + (cd "$TMP" && sha256sum -c "$SHASUM") || fail "checksum verification failed" +elif command -v shasum >/dev/null 2>&1; then + (cd "$TMP" && shasum -a 256 -c "$SHASUM") || fail "checksum verification failed" +else + fail "neither sha256sum nor shasum found — cannot verify the download" +fi + +echo "==> Extracting ..." +tar xzf "$TMP/$ASSET" -C "$TMP" + +BIN="$BASE" +[ "$OS" = "windows" ] && BIN="${BIN}.exe" +[ -f "$TMP/$BIN" ] || fail "archive did not contain $BIN" + +# --- Install --- +INSTALL_DIR="${GIT_STATS_INSTALL_DIR:-$HOME/.local/bin}" +mkdir -p "$INSTALL_DIR" + +TARGET="git-stats" +[ "$OS" = "windows" ] && TARGET="git-stats.exe" + +if ! install -m 755 "$TMP/$BIN" "$INSTALL_DIR/$TARGET" 2>/dev/null; then + # BusyBox and some minimal images ship no install(1). + cp "$TMP/$BIN" "$INSTALL_DIR/$TARGET" && chmod 755 "$INSTALL_DIR/$TARGET" +fi + +echo "==> git-stats v${VERSION} installed to $INSTALL_DIR/$TARGET" + +# --- Point the user at the next step --- +case ":$PATH:" in + *":$INSTALL_DIR:"*) ;; + *) + echo "" + echo "$INSTALL_DIR is not in your PATH. Add it:" + echo " export PATH=\"$INSTALL_DIR:\$PATH\"" + ;; +esac + +echo "" +echo "Next:" +echo " git-stats version" +echo " echo 'GIT_STATS_REPO=owner/name' > .env # the repository to track" +echo " git-stats collect" diff --git a/internal/version/version.go b/internal/version/version.go index 79425bf..2c7b276 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -1,10 +1,39 @@ -// Package version carries the build's version string. +// Package version reports the build's version string. package version +import ( + "runtime/debug" + "strings" +) + // Version is the release this binary was built from, without a leading "v". // The release workflow injects the tag with // // -ldflags "-X github.com/dejo1307/git-stats/internal/version.Version=1.2.3" // -// so an unstamped local build reports "dev" rather than claiming a release. -var Version = "dev" +// It is empty in any build that did not go through that workflow; call String +// rather than reading it directly. +var Version = "" + +// String reports the version, falling back to the module version the Go +// toolchain records and finally to "dev". +// +// The fallback is what makes `go install github.com/dejo1307/git-stats/...@v1.2.3` +// honest: that path applies no ldflags, so without it every proxy-installed +// binary would claim to be "dev" while being a tagged release. It also gives a +// build from a git working tree something better than "dev" — the toolchain +// derives a version from the nearest tag and marks uncommitted changes, so a +// local build reports e.g. "1.2.3+dirty". +func String() string { + if Version != "" { + return Version + } + if info, ok := debug.ReadBuildInfo(); ok { + // "(devel)" is what the toolchain records when it has no version to + // report at all — no tag, or no VCS information stamped in. + if v := info.Main.Version; v != "" && v != "(devel)" { + return strings.TrimPrefix(v, "v") + } + } + return "dev" +} diff --git a/internal/version/version_test.go b/internal/version/version_test.go new file mode 100644 index 0000000..7d01a1b --- /dev/null +++ b/internal/version/version_test.go @@ -0,0 +1,31 @@ +package version + +import "testing" + +// An injected version wins over anything the toolchain recorded — that is the +// path every published release takes. +func TestStringPrefersInjectedVersion(t *testing.T) { + prev := Version + t.Cleanup(func() { Version = prev }) + + Version = "1.2.3" + if got := String(); got != "1.2.3" { + t.Errorf("String() = %q, want 1.2.3", got) + } +} + +// With nothing injected, String must still name something — never an empty +// string, and never a leading "v" that would not match a release tag's assets. +func TestStringFallsBack(t *testing.T) { + prev := Version + t.Cleanup(func() { Version = prev }) + + Version = "" + got := String() + if got == "" { + t.Fatal("String() = empty, want a version or \"dev\"") + } + if got[0] == 'v' { + t.Errorf("String() = %q, want no leading \"v\"", got) + } +}